2024-12-21 17:57:34
最近,微软开源了一个名为 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 真的是浏览器的未来。
遇到的主要挑战和解决方案:
文件传输问题:如何将用户选择的文件传递给 Worker 中的 Python 运行时?
依赖安装问题:PyPI 在中国大陆访问受限。
最终,我们成功实现了一个完全运行在浏览器中的 MarkItDown 工具。欢迎访问 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 })
}
}
2024-12-21 16:17:40
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,
}
2024-12-18 21:06:14
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 都可以搭建,但各有优缺点:
优点:免费计划可用。
缺点:会产生很多 Worker 请求,可能超出免费计划后不可用或需要付费。
优点:不产生 Worker 请求,支持大量使用。 缺点:Snippets 目前只有 Pro 以上计划使用,Free 不可用。
对应代码已经开源,地址:
2024-08-31 13:14:00
最近准备把部署在 Cloudflare, Vercel, Netlify 上的项目迁移到自己的 VPS 通过 Docker 运行,就复习了一下 Docker 镜像打包。 但是一个很小的项目打包出来就是 1.05GB, 这显然是不能接受的。所以研究了一下 Node.JS 项目 Docker 镜像最小化打包方案, 将镜像大小从 1.06GB 缩小到了 135 MB。
示例项目是一个 Astro 项目, 使用 Vite 作为构建工具, SSR 模式运行。
主要思路是使用最小化系统镜像,选用 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
主要思路是先安装生产环境依赖,产生第一层。 再安装全量依赖,打包生成 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
主要思路是将 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 查看。
2024-08-11 17:18:12
之前一直在 X 分享一些有趣的工具,而且也会同步到 Telegram Channel 上。看到 Austin 说准备建立一个网站,把分享内容都收录进去。 刚好想到最近看到的一个模板 Sepia,就想到把 Telegram Channel 转成微博客。
难度不大,主功能一个周末就搞完了。过程中做到了浏览器端 0 JS, 分享一些有趣的技术点:
防剧透模式和移动端搜索框隐藏展示,使用的 CSS ":checked 伪类" 和 "+ 紧邻兄弟组合器" 实现的,参考
过渡动画使用的 CSS View Transitions, 参考
图片灯箱用的 HTML 的 popover 属性,参考
返回顶部的展示和隐藏使用 CSS animation-timeline 实现 Chrome 115 以上版本专属,参考
多图瀑布流布局,使用 grid 布局实现,参考
访问统计使用一个 1px 的透明图片做 LOGO 背景,上古技术了,现在几乎没有访问统计软件支持了
禁止浏览器端 JS 运行,使用的 Content-Security-Policy 的 script-src 'none' 参考
搞完以后,就直接开源了,很意外有那么多人喜欢,一周就收获了 800+ 的 star 了。
如果你也有兴趣,可以去 GitHub 上看看。
2024-07-29 21:39:55
最近在准备给家用网络升级 2.5G, 在咸鱼收了一台阿里云 AP8220 来折腾。 但是这个机器的刷机资料太少了。折腾了2天才成功刷机。所以写一篇文档记录一下。
非必要,不刷机。
刷机有风险,请做好刷砖准备。
任何刷机问题与我无关,我只分享我的刷机过程。
使用 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
nand device 1
nand erase 0x50000 0x10000
nand write $fileaddr 0x50000 $filesize
刷完以后,断电。
设备重新通电,进入 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
。