普通视图

发现新文章,点击刷新页面。
昨天以前码志

如何在 IT 运维中节省开支

作者 Zhuang Ma
2025年12月9日 00:00

当前,降本增效已成为各行各业的共识,如何在业务和支撑环节中有效节省开支,几乎是每个从业者必须面对的问题。本文将结合个人经验,记录一些在 IT 运维实践中节省成本的措施。

0x01 断舍离

定期梳理闲置的资源和服务,及时关闭不必要的项目。

例如:

  • 对于因计划调整而搁置的项目,预留的资源应尽早下线;
  • 对于因业务调整而不再需要的服务和资源,应及时停用;
  • 在运行过程中发现长期闲置的资源,应及时释放。

0x02 高转低

根据实际使用情况,评估当前资源配置是否过高。若发现资源利用率较低,可以考虑将高配资源替换为低配资源,以节省费用。

例如,服务器和数据库实例等,可以根据 CPU、内存、存储等指标,在满足业务需求的前提下,选择更经济的规格。

此外,可以考虑通过性能优化,降低资源需求,从而使用更低配置的资源。

0x03 商转免

使用免费或开源软件替代付费软件

例如,一些存储中间件、消息中间件和监控系统等,可以考虑使用成熟的开源软件替代商业软件,以节省费用。

当然,在选择开源软件时,需考虑其功能和性能是否满足要求,同时也要评估运维和支持成本。

使用免费证书替代付费证书

对于一些小公司和个人网站,可以考虑使用 Let’s Encrypt 等免费证书颁发机构提供的 SSL 证书,以节省证书费用。详见我的另一篇文章:借助 Let’s Encrypt 节省 SSL 证书费用

0x04 优策略

在采购硬件、软件和服务时,可以通过一些策略节省成本:

  • 如果采购量较大,可以尝试与供应商谈判,争取更优惠的价格;
  • 选择合适的时机进行采购,不急用的资源可以在促销季统一采购;
  • 在商业服务的计费模式选择上,按量付费和包年包月各有优劣,结合实际使用情况选择最合适的方式,并按需调整。

0x05 常复盘

定期进行成本复盘,检查是否有可以优化的细节。

例如:

  • 年代久远的视频文件,可以通过精简多清晰度副本来节省存储成本;
  • 关注云平台的日常费用消耗,查看服务商是否推出新的资源包以抵扣费用;

这是一个持续优化的过程,为公司节省的每一分钱,都可能是在寒冬中生存下去的机会。

您还有哪些有效的节省成本经验,期待评论交流!

借助 Let’s Encrypt 节省 SSL 证书费用

作者 Zhuang Ma
2025年11月27日 00:00

向服务商购买一张常见的 DV 通配符 SSL 证书,通常每年价格在数百至一千多元人民币不等;若名下有多个域名需要使用证书,总费用每年可能达到数千元。

在当前强调降本增效的环境下,若评估后认为免费证书能够满足需求,小公司和个人网站即可节省相应成本。

Let’s Encrypt 简介

Let’s Encrypt 是一家免费、开放、自动化的公益性证书颁发机构(CA),由互联网安全研究组(ISRG)运作,属于非营利组织。其目标是推广 HTTPS 的应用,为构建更安全、尊重隐私的互联网提供免费而便捷的支持。

操作方法

根据不同使用环境,Let’s Encrypt 提供多种验证与获取证书的方式。常用工具是 Certbot,详见文档:https://eff-certbot.readthedocs.io/en/stable/

在部分环境中,可配置工具定期自动续期,减少维护工作。

由于服务器环境较为老旧,且需要将证书上传至阿里云并部署到多个云服务,本文暂采用“本地生成证书—手动上传与更新”的方式。

0x01 在本地生成证书

本文使用 Docker 运行 Certbot,参见文档:https://eff-certbot.readthedocs.io/en/stable/install.html#alternative-1-docker

生成通配符证书的示例命令如下:

docker run -it --rm --name certbot \
  -v '/Users/mazhuang/some/path/letsencrypt:/etc/letsencrypt' \
  certbot/certbot certonly \
  --preferred-challenges dns \
  --manual \
  --server https://acme-v02.api.letsencrypt.org/directory \
  --key-type rsa --rsa-key-size 2048
  • --preferred-challenges dns:使用 DNS 方式进行域名验证;
  • --manual:以交互式方式进行询问与操作;
  • --key-type rsa --rsa-key-size 2048:生成 2048 位 RSA 私钥(部分阿里云服务不支持默认的 ECC 证书)。

执行后会依次询问邮箱、协议授权、域名等信息,随后提示添加 DNS TXT 记录以完成域名所有权验证,按提示操作即可。

生成成功后,证书与私钥保存在挂载的本地目录中,例如上述命令中的 /Users/mazhuang/some/path/letsencrypt/archive/{domain name}。各文件的说明可参考:https://eff-certbot.readthedocs.io/en/stable/using.html#where-certs

0x02 上传和部署证书

将证书上传到阿里云的数字证书管理服务。可使用其一键部署功能(付费),或在各云服务中手动选择使用该证书(免费),按需取用。

0x03 定期更新证书

Let’s Encrypt 颁发的证书有效期为 90 天,建议在到期前 30 天内更新。可重复步骤 0x01 生成新证书,然后上传并部署。

注意事项

部分极为老旧的平台有可能不支持 Let’s Encrypt 颁发的证书,建议评估后再决定是否使用,具体的兼容情况可以参考:https://letsencrypt.org/zh-cn/docs/certificate-compatibility/

比如我这边就遇到了因为使用的是 JDK 8 的低于 141 的版本,部署完证书后,发现 xxl-job 定时任务执行器没有注册上,报错 sun.security.validator.ValidatorException: PKIX path building failed

解决方法:

  1. 下载 ISRG Root X1 证书

    在这里可以找到: https://letsencrypt.org/certificates/

     cd /opt
     get https://letsencrypt.org/certs/isrgrootx1.pem
    
  2. 导入证书到 JDK 的 cacerts 中

     keytool -trustcacerts -keystore "/opt/jdk/jre/lib/security/cacerts" -storepass changeit -noprompt -importcert -alias lets-encrypt-x1 -file "/opt/isrgrootx1.pem"
    
  3. 重启服务

小结

以上步骤简单、成本为零。对小公司和个人网站而言,是节省 SSL 证书费用的可行方案。

若环境允许,建议配置自动化续期,进一步降低维护成本,按需采用。

参考链接

  • https://letsencrypt.org/zh-cn/
  • https://eff-certbot.readthedocs.io/en/stable/

解决访问 https 网站时,后端重定向或获取 URL 变成 http 的问题

作者 Zhuang Ma
2025年11月14日 00:00

一种常见的服务部署架构是 Nginx 反向代理后端 Java 应用服务器,Nginx 监听 443 端口处理 https 请求,然后转发给后端服务器。

对应的 Nginx 配置大致如下:

upstream www {
    server 192.168.1.101:8080  weight=100 max_fails=3 fail_timeout=10s;
    server 192.168.1.102:8080  weight=100 max_fails=3 fail_timeout=10s;
}

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://www;
    }
}

即:客户端与 Nginx 之间是 https,Nginx 与后端 Java 应用服务器之间是 http。

这样可能会遇到一些问题,如:

  1. HttpServletRequest.getRequestURL() 获取到的 URL 是 Nginx 与后端服务器之间的 http URL,比如 http://192.168.1.101:8080/xxx
  2. HttpServletResponse.sendRedirect() 生成的重定向 URL 也是 http URL。

要解决这些问题,可以通过 Nginx 配置 + 少量后端代码修改来实现。

解决应用中获取到的 URL 的问题

用户实际访问的是 https://example.com/xxx,但是后端应用获取到的 URL 是 http://192.168.1.101:8080/xxx,如何让后端应用获取到正确的 URL 呢?

第一步,Nginx 可以通过 proxy_set_header Host 指令将客户端请求的 Host 头传递给后端服务器:

location / {
    # ...
    proxy_set_header Host $host;
}

这样,后端应用通过 HttpServletRequest.getRequestURL() 获取到的 URL 就是 http://example.com/xxx 了。

但此时,协议仍然不对,还是 http。

要给后端应用传递正确的协议,通常的做法是使用 X-Forwarded-Proto 头:

location / {
    # ...
    proxy_set_header X-Forwarded-Proto $scheme;
}

添加这个头之后并不会让 HttpServletRequest.getRequestURL() 直接返回 https URL,需要在后端应用中做一些处理。以 Java 应用为例,可以通过一个过滤器(Filter)来修改 request 的 scheme:

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class XForwardedProtoFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        if (request instanceof HttpServletRequest) {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            String xForwardedProto = httpRequest.getHeader("X-Forwarded-Proto");
            if (StringUtils.isNotBlank(xForwardedProto) && !xForwardedProto.equalsIgnoreCase(httpRequest.getScheme()) && xForwardedProto.equalsIgnoreCase("https")) {
                httpRequest = new HttpServletRequestWrapper(httpRequest) {
                    @Override
                    public String getScheme() {
                        return xForwardedProto;
                    }

                    @Override
                    public StringBuffer getRequestURL() {
                        StringBuffer requestURL = super.getRequestURL();
                        if (requestURL != null && requestURL.length() > 0) {
                            int index = requestURL.indexOf("://");
                            if (index > 0) {
                                requestURL.replace(0, index, xForwardedProto);
                            }
                        }
                        return requestURL;
                    }
                    
                };
            }
            chain.doFilter(httpRequest, response);
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void destroy() {
    }
}

至此,后端应用通过 HttpServletRequest.getRequestURL() 获取到的 URL 就是 https://example.com/xxx 了。

解决重定向 URL 的问题

后端应用通过 HttpServletResponse.sendRedirect() 生成的重定向 URL 也是 http URL,如何让它变成 https 呢?

这个问题可以通过 Nginx 的另一指令 proxy_redirect 来解决,该指令用于修改从后端服务器返回的 LocationRefresh 响应头。

location / {
    # ...
    proxy_redirect http:// $scheme://;
}

这样,当后端应用返回一个重定向响应时,Nginx 会将 Location 头中的 http:// 替换为 $scheme://,即 https://

进一步思考:当 Nginx 前面还有负载均衡器时

在很多情况下,Nginx 前面可能还有商用负载均衡器(如 AWS ELB、阿里云 SLB 等),这时需要考虑负载均衡器与 Nginx 之间的协议问题。

如果负载均衡器与 Nginx 之间是 http,而 Nginx 与后端应用之间是 http,那么就需要在负载均衡器和 Nginx 之间添加 X-Forwarded-Proto 头,以便 Nginx 能够正确地识别原始请求的协议。

主流的负载均衡器配置项里应该都有添加 X-Forwarded-Proto 头的选项开关,比如阿里云:

需要注意的是这样配置后,Nginx 配置也需要做相应的调整,将 $scheme 替换为 $http_x_forwarded_proto: (此种场景 $scheme 为负载均衡器与 Nginx 之间的协议 http,$http_x_forwarded_proto 为负载均衡器通过 Header 透传过来的前端访问协议 https。)

location / {
    # ...
    proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
    proxy_redirect http:// $http_x_forwarded_proto://;
}

参考链接

清除 GitHub 上的幽灵通知

作者 Zhuang Ma
2025年10月15日 00:00

最近我的 GitHub 页面右上角一直有个小蓝点,就像这样:

这是有未读通知的指示,但点进去却什么也看不到。

这种「幽灵通知」已经干扰了我的正常使用体验。这天实在忍无可忍,正打算给 GitHub 提交一个工单时,在官方开设的讨论区里发现了一个讨论贴,我在里面找到了有效的临时解决办法,特此记录下来以备后用。

讨论贴链接:https://github.com/orgs/community/discussions/6874

方案一

我使用的是讨论里提到的 利用浏览器开发者工具的解决方案

  1. 在浏览器打开通知列表页面,点击左侧标记有数字的 Filters 或者 Repositories,比如我上文贴的图里的 ParticipatingMentioned,或者 outcaster552/gitcoinpromosendergitcionoda/orgyycombinator/-coparadigm-ventures/paradigm 等等。

  2. 打开浏览器的开发者工具(F12),切换到 Console 标签页,粘贴以下代码并回车执行:

     document.querySelector('.js-notifications-mark-all-actions').removeAttribute('hidden');
     document.querySelector('.js-notifications-mark-all-actions form[action="/notifications/beta/archive"] button').removeAttribute('disabled');
    
  3. 这时页面上会出现一个 Done 按钮,点击它即可清除对应的通知。

方案二

如果以上办法没有解决问题,还可以试一下贴子里 被标记为答案的回复 是一个借助 curl 命令的解决方案。

curl -X PUT
    -H "Accept: application/vnd.github.v3+json" \
    -H "Authorization: token $TOKEN" \
     https://api.github.com/notifications \
    -d '{"last_read_at":"2025-10-15T10:00:00Z"}'

其中 $TOKEN 需要替换成你自己的 GitHub 个人访问令牌(Personal Access Token),可以在 https://github.com/settings/tokens/new 创建,注意 Select scopes 里需要勾选 notifications。命令里的 last_read_at 字段的值可以按需修改为当前时间。

结语

这种幽灵通知,看情况应该来自于某些居心不良的开发者,在他们的仓库里恶意 at 大量的用户来引流,然后这些用户就会收到通知。但如果这些仓库后来因为违规被删除,那么这些通知就会变成幽灵通知,无法被正常清除。

看讨论的时间线,这个问题已经存在至少四年了,GitHub 官方似乎并没有打算修复它。希望本文能帮到和我有同样困扰的朋友。

Java|FreeMarker 复用 layout

作者 Zhuang Ma
2025年8月30日 00:00

项目里的页面一多,重复的页面布局就不可避免地冒了出来,作为程序员,消除重复,义不容辞。那么,今天就来聊聊如何在 FreeMarker 中复用页面 layout,让代码更优雅、更易维护。

常规做法:include

FreeMarker 提供了 include 指令,可以把一些公共页面元素单独提取出来,然后在需要的地方通过 include 引入,例如:

<#-- includes/header.ftl -->
<p>我来组成头部</p>
<#-- includes/footer.ftl -->
<p>我来组成底部</p>
<#-- somepage.ftl -->
<#include "./includes/header.ftl">

<p>我是页面内容</p>

<#include "./includes/footer.ftl">

<script>
// 这里是一些 JavaScript 代码
</script>

去除重复:抽象 layout

但是所有类似的页面都要手写这个结构也挺麻烦的,更糟糕的是,一旦这些页面的结构发生变化,得在 N 个页面里反复修改,想想都头大。

很多博客引擎(比如 Jekyll)都支持 layout 功能,允许我们定义统一的页面布局,具体页面只需专注于内容。

FreeMarker 虽然没有内置 layout,但我们可以用 macro 来实现类似的效果。

比如,抽象出一个 layout/page.ftl 文件,作为布局模板:

<#-- layout/page.ftl -->
<#macro layout body js="">

<#include "../includes/header.ftl" />

${body}

<#include "../includes/footer.ftl" />

${js}

</#macro>

然后在需要的页面这样用:

<#import "./layout/page.ftl" as base>

<#assign body>

<p>我是页面内容</p>
<p>当前时间:<span id="current-time">${.now?string("yyyy-MM-dd HH:mm:ss")}</span></p>

</#assign>

<#assign js>

<script>
// 每隔一秒刷新当前时间
setInterval(function() {
    document.getElementById("current-time").innerHTML = new Date().toLocaleString();
}, 1000);
</script>

</#assign>

<@base.layout body=body js=js />

页面效果如下:

减少手工输入:code snippets

虽然布局复用问题解决了,但每次新建页面还得手写一遍结构,还是不够优雅。程序员的信条是:能自动化的绝不手动!

这时就轮到编辑器/IDE 的 code snippets 功能登场了。把上面的结构定义成代码片段,新建页面时只需输入一个触发词,基本结构就自动生成。

以 VSCode 为例,可以在项目的 .vscode 目录下新建 layout.code-snippets 文件,内容如下:

{
    "page_layout": {
        "scope": "ftl",
        "prefix": "layout:page",
        "body": [
            "<#import \"./layout/page.ftl\" as base>",
            "",
            "<#assign body>",
            "",
            "",
            "",
            "</#assign>",
            "",
            "<#assign js>",
            "",
            "<script>",
            "",
            "</script>",
            "",
            "</#assign>",
            "",
            "<@base.layout body=body js=js />"
        ],
        "description": "Page layout template for FTL files"
    }
}

这样新建 .ftl 文件后,输入 layout:page,页面布局结构就自动生成了。

如图所示:

IntelliJ IDEA 也可以用 Live Templates 实现同样的效果。

本文相关代码和示例已上传至 GitHub,见 https://github.com/mzlogin/learn-spring 的 freemarker-test 目录。

DIY|Mac 搭建 ESP-IDF 开发环境及编译小智 AI

作者 Zhuang Ma
2025年6月5日 00:00

前一阵子在百度 AI 开发者大会上,看到基于小智 AI DIY 玩具的演示,感觉有点意思,想着自己也来试试。

如果只是想烧录现成的固件,乐鑫官方除了提供了 Windows 版本的 Flash 下载工具 之外,还提供了基于网页版的 ESP LAUNCHPAD,按照说明在 Mac 上也可以使用。

而我想着后期做一些定制,所以还是需要在 Mac 上搭建 ESP-IDF 开发环境,自己编译和烧录固件。而这个在 小智 AI 聊天机器人百科全书 中没有详细提及,所以我就记录一下搭建过程,供有需要的朋友参考。

先上一个跑起来后的效果:

配置 macOS 平台工具链

这一步参考乐鑫官方的 Linux 和 macOS 平台工具链的标准设置 完成,我这里指定了使用 ESP-IDF v5.4.1 版本,编译目标是 ESP32-S3。

第一步:安装前置依赖

brew install cmake ninja dfu-util ccache python3

第二步:获取 ESP-IDF

mkdir ~/github
cd ~/github
git clone -b v5.4.1 --recursive https://github.com/espressif/esp-idf.git

ESP-IDF 将下载至 ~/github/esp-idf 目录。

第三步:设置工具

cd ~/github/esp-idf
./install.sh esp32s3

第四步:设置环境变量

在 ~/.zshrc 中添加以下内容:

alias get_idf='. $HOME/github/esp-idf/export.sh'

然后 source ~/.zshrc 使其生效。

这样在需要用到 ESP-IDF 环境的时候,只需要在终端中执行 get_idf 即可。

在执行以上步骤时,如果遇到问题,可以到 乐鑫官方文档 里看看有没有解决方案。

下载和编译小智 AI 固件

cd ~/github
git clone -b v1.6.2 git@github.com:78/xiaozhi-esp32.git
cd xiaozhi-esp32

然后接入 ESP32-S3 开发板,执行以下命令:

get_idf
idf.py set-target esp32s3
idf.py build
idf.py flash monitor

一切顺利的话,会向 ESP32-S3 开发板烧录小智 AI 固件,并且进入监控模式。

至此,就初步能跑起来了。按照提示进行 WiFi 配置和小智 AI 平台的设备绑定,即可开始使用。

如果后续需要定制固件,可以基于 ~/github/xiaozhi-esp32 目录进行修改和编译。若习惯使用 VSCode 进行开发,可以安装 适用于 VSCode 的 ESP-IDF 扩展,这样可以更方便地进行开发和调试。

参考链接

Mac mini 外接三方键盘如何微调音量

作者 Zhuang Ma
2025年5月8日 00:00

用 Mac mini 外接第三方键盘时,音量调节可能会让人感到痛苦。比如,Fn + F11Fn + F12 这对音量调节快捷键可能没法用,而基于它们的微调组合键(Fn + Option + Shift + F11Fn + Option + Shift + F12)更是想都别想。

我的工作键盘是 IKBC C87,虽然有个 Fn 键,但它和 Mac 键盘的 Fn 完全不是一回事。键盘自带的音量调节组合键只能一格一格地调节,听歌时还好,但在 Coding 时,这“一格”的音量有时就显得过于喧闹,让人无法沉浸式思考。微调音量成了刚需。

最终,Karabiner-Elements 拯救了我。

设置方法

只需将右 Ctrl 键映射为 Fn 键,就能像用苹果妙控键盘一样,正常使用各种基于 Fn 的快捷键,包括音量微调。

关于 Karabiner-Elements

Karabiner-Elements 是一款强大的键盘映射工具,功能远不止映射单键这么简单。它还能:

  • 创建自定义快捷键组合;
  • 设计复杂的键盘规则;
  • 根据不同应用程序或环境动态调整键盘映射。

此外,官网还提供了丰富的规则库,按需导入即可:https://ke-complex-modifications.pqrs.org/


现在,我终于可以在 Coding 时享受“刚刚好”的背景音乐,而不用被“一格音量”的霸道支配。

希望能帮到和我一样有此困扰的你。

家里老人迷信医疗广告?我可能找到办法了

作者 Zhuang Ma
2025年3月21日 00:00

有没有这样一种奇妙体验:家里老人对你的忠告嗤之以鼻,却对网络医疗广告深信不疑?仿佛那些”三天速效”“纯天然”“祖传秘方”字样自带某种魔力,能让他们心甘情愿掏空钱包?

我爸就是这样。医院检查出肠道息肉后,他不愿接受正规治疗,却在网上找了个不知名的乡镇医院,买了一堆贵得离谱的中药。当我劝他去本市三甲医院时,我们吵了一架,不欢而散。

我百思不得其解:为什么长辈会信任网上随机广告,胜过亲生儿女的劝告?是年代差异让他们天然信任媒体?是搜索引擎大品牌的背书效应?还是他们骨子里相信”酒香也怕巷子深,神医总在小诊所”?

曾经,我尝试给他安装丁香医生,希望提供专业的医疗参考。结果他嫌上面评论太少,不够可信。转头却给我展示抖音上的”体外无痛胃肠检查”广告——零评论,明显广告标识。我内心:这双标技术堪称奥运冠军水平啊!

绝望之际,我发现了转机。

一天晚上,我爸向我展示他用”豆包”AI制作的视频。灵光乍现!我让他用豆包查询那个”神奇”的体外检查技术。豆包给出了客观分析,而他竟然接受了这个意见!

第二天他主动用豆包查询其他健康问题,我立刻顺水推舟:”以后不用上百度了,直接问豆包就好。”他居然欣然接受!

所以,我所谓的方法其实很简单:用 AI 助手替代浏览器和搜索引擎。无论是豆包、DeepSeek 还是其他 AI 产品,它们提供简洁明了的单一答案,不会展示五花八门的广告链接,也(暂时)不会被商业利益左右。

感谢技术进步,我终于不用在”爸,这是骗人的”和”儿子,你懂什么”之间无限循环了。

类似的救赎,tk 教主的经历显然更加硬核,但我可能来不及去建立那样的信任了:

将微信公众号文章同步到阿里云开发者社区

作者 Zhuang Ma
2024年10月31日 00:00

本文介绍了一种通过自己拓展的浏览器插件,便捷地将微信公众号文章同步到阿里云开发者社区的方法。

先上效果图:

缘起

半个多月前接到 讨厌菠萝 同学的盛情邀请,入驻阿里云开发者社区,他也不时地监督我将以前的文章同步过来,奈何我懒癌晚期,一直没怎么动。

但心里其实一直记着这个事的,答应了人家,总得做点什么。

一篇一篇地手动复制粘贴实在太费劲了,也不是程序员做事的风格,于是就想着能不能找个什么工具,能帮我简化这个过程。

最后没有找到什么现成的,只好自己扩展了一个,下面就来介绍一下我的方案。

方案

一文多发的需求其实我一直都有,以前也陆续用过 OpenWrite、ArtiPub 等工具,后来因为种种原因,只留下了 Wechatsync 这个浏览器插件。

我现在发布文章一般是先在微信公众号发布,然后再通过 Wechatsync 同步到知乎、掘金等平台。

看了下 Wechatsync 插件的原始代码仓库,目前并不支持 阿里云开发者平台。源码的最后一次更新时间停留在 2023 年 9 月,Issues 里也有人反馈了一些问题,但是作者好像没怎么回复了。

那就自己动手,丰衣足食吧,扩展一下 Wechatsync,让它支持阿里云开发者社区。

实现

了解扩展 Wechatsync 的方法

这个在 Wechatsync 的官方 API 文档里有介绍:

https://github.com/wechatsync/Wechatsync/blob/master/API.md

简而言之,如果要新增一个平台的支持,需要添加一个适配器,适配器需实现以下方法来完成工作流程:

  • getMetaData 获取平台用户信息
  • preEditPost 对文本内容进行预处理
  • addPost 向平台添加文章
  • uploadFile 向平台上传文章里的图片(然后插件会进行内容替换)
  • editPost 向平台更新替换图片后的文章内容

分析阿里云开发者社区的接口

打开阿里云开发者社区的网站 https://developer.aliyun.com/ ,登录后,打开浏览器的开发者工具,尝试进行发布文章必要的操作,查看网络请求,找到了一些接口:

  • 新建/保存草稿:/developer/api/articleDraft/putDraft
  • 获取上传图片 URL:/developer/api/image/getImageUploadUrl
  • 获取个人信息:/developer/api/my/user/getUser

有了这些,基本就够了。

实现适配器

作为一名前端初学者,对照着 Wechatsync 的源码里面其它的适配器,最终编写和调试完成了阿里云开发者社区的适配器。

就不把源码直接贴出来水篇幅了,有兴趣的可以去我的 GitHub 仓库查看,适配器源码直达链接:

写完适配器之后,再在 packages/web-extension/src/drivers/driver.js 文件里作少量修改,将其集成,然后就可以正常使用了。

使用方法

安装

Wechatsync 的原始作者是在 Chrome 商店上架了该插件的,只是版本不是最新。我 fork 出来做的一些修改主要自己使用,所以只是在自己的 fork 仓库里发布了最新版本,如果想要使用的话,可以用开发者模式加载:

  1. 下载并解压:https://github.com/mzlogin/Wechatsync/releases
  2. 在浏览器打开 chrome://extensions(适用 Chrome、Edge 等)
  3. 开启开发者模式
  4. 拖入解压后的文件夹到浏览器插件页

使用

以我将微信公众号的文章同步到阿里云开发者社区为例:

  1. 登录阿里云开发者社区
  2. 打开微信公众号文章页
  3. 点击 Wechatsync 插件图标
  4. 勾选「阿里云开发者社区」,点击「同步」按钮
  5. 点击「查看草稿」,确认无误后,发布文章

整个操作如文首的 gif 所示。

小结

这个方案虽然不算完美,但是对我来说已经足够了,省去了很多重复的劳动,也算是一种效率提升吧。

如果你也有类似的需求,可以参考我的方案,或者自己动手扩展 Wechatsync,让它支持更多的平台。

本文所述相关源码已经提交到了我 fork 出来的 GitHub 仓库,供参考:

Android|使用阿里云推流 SDK 实现双路推流不同画面

作者 Zhuang Ma
2024年8月13日 00:00

本文记录了一种使用没有原生支持多路推流的阿里云推流 Android SDK,实现同时推送两路不同画面的流的方法。

需求

项目上有一个用于直播的 APP,可以在 Android 平板上录屏进行推流直播,然后通过阿里云的直播转 VOD 方式形成录播视频。屏幕上的内容分为 A、B 两个区域,如图所示:

原本是只推送 A 区域的画面,也就是用户观看直播和录播时,都只能看到 A 区域。

现在要求变成用户观看直播时,可以看到 A 区域的内容;而观看录播时,可以同时看到 A 区域和 B 区域的内容。

思路

大致思考后,有两种思路:

一种是推流时,推送 A 区域 加 B 区域的画面,然后在观看直播时,将画面进行截取展示,在录播时则直接展示完整画面;

另一种是推流时,分别推送两路流,一路只推送 A 区域,另一路则推送 A 区域 加 B 区域,观看直播时拉第一路流,观看录播时展示第二路流转的 VOD 视频。

基于项目的现状——推流只有一个 Android 端,而播放则需要适配 Web PC、Web Mobile、Android 和 iOS,所以选择了第二种方案,这样只需对推流端进行改造,然后服务端接口进行简单调整即可。

方案

一个残酷的现实是,阿里云推流 Android SDK 并没有原生支持多路推流的功能,官方文档有提到:

AlivcLivePusher目前不支持多实例

经过尝试,发现在一个进程里创建两个 AlivcLivePusher 实例,确实无法同时正常使用。

那只能自己想点黑科技了……

最终的实现方案:

简单来讲,就是在推流时,启动位于另一个进程的 Service,初始化另一个 AlivcLivePusher,进行第二路推流。

每当有新的视频帧时,写入 MemoryFile,然后通过 AIDL 调用将 ParcelFileDescriptor 传递给 Service,Service 读取 MemoryFile,进行处理后推给第二路流。

音频帧同理。

小结

经过一些调试和优化,最终实现了需求里想要的双路推流不同画面的效果。

这种方式虽然有点绕,但是在没有原生支持的情况下,也算是一种可用的方案了。

如果没有项目和业务的历史包袱,可以优先考虑使用原生支持多路推流的 SDK,比如大牛直播等,这样可能会更加方便和稳定。

iOS|一个与 NSDateFormatter 有关的小 Bug

作者 Zhuang Ma
2024年7月5日 00:00

我们的 iOS APP 有一个小 Bug,场景简化后是这样:

接口返回一个时间字符串,APP 里比较它与当前时间,如果当前时间晚于它,就显示一个按钮,否则不显示。

本来是一个很简单的逻辑,但是,有一部分用户反馈,按钮该显示的时候却没有显示。

分析

结合用户反馈的信息,经过多次尝试后,才发现这个行为竟然与用户手机的时间制式有关——如果用户手机设置里的 24小时制 开关没有打开,那么这个 Bug 就会出现。

相关的逻辑是这样写的:

NSDate *remoteDate = [NSDate dateFromStr:remoteDateString];
if (remoteDate) {
    // 比较 remoteDate 和 本地当前时间,控制按钮显隐
}

这个 dateFromStr: 是一个 category 方法,实现是这样的:

+ (NSDate*)dateFromStr:(NSString *)dateStr {
    NSDateFormatter * dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    return [dateFormatter dateFromString:dateStr];
}

经过调试,发现 remoteDate24小时制 开关关闭时,返回的是 nil,而在打开时,返回的是正确的时间。

苹果官方文档里,NSDateFormatterdateFromString: 方法是这样描述的:

Returns a date representation of a given string interpreted using the receiver’s current settings.

Return Value A date representation of string. If dateFromString: can’t parse the string, returns nil.

同时还给出了 Working With Fixed Format Date Representations 的参考链接,里面有说明:

When working with fixed format dates, such as RFC 3339, you set the dateFormat property to specify a format string. For most fixed formats, you should also set the locale property to a POSIX locale (“en_US_POSIX”), and set the timeZone property to UTC.

这个页面里还给出了一个 QA 链接 Technical Q&A QA1480 “NSDateFormatter and Internet Dates”,里面有这样的描述:

On iOS, the user can override the default AM/PM versus 24-hour time setting (via Settings > General > Date & Time > 24-Hour Time), which causes NSDateFormatter to rewrite the format string you set, which can cause your time parsing to fail. … On the other hand, if you’re working with fixed-format dates, you should first set the locale of the date formatter to something appropriate for your fixed format.

里面提到了用户可以通过设置 24小时制 来影响 NSDateFormatter 的行为,还提到了当尝试把固定格式的日期字符串转换成日期对象时,应该设置 locale

至此破案了,这个 Bug 就是由于没有设置 NSDateFormatterlocale 属性导致的。

解决

修改后的代码是这样的,仅加了一行 locale 设置:

+ (NSDate*)dateFromStr:(NSString *)dateStr {
    NSDateFormatter * dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
    [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"zh_CN"]];
    return [dateFormatter dateFromString:dateStr];
}

经过测试功能正常了,不管用户手机的 24小时制 开关是否打开,都能正常解析服务端返回的时间字符串了。

参考

Android|WebView 禁止长按,限制非白名单域名的跳转层级

作者 Zhuang Ma
2024年6月25日 00:00

最近 Android APP 项目接到少量用户反馈,说在隐私协议的界面上,有两种方式可以跳到百度搜索页面:

  1. 长按选择部分文字,然后在弹出的菜单中选择「搜索」,系统会打开浏览器进入百度搜索页面;
  2. 点击隐私协议里的三方 SDK 的隐私协议链接,然后经过一系列的点击和跳转,最终可以在 APP 内进入百度搜索页面。

他们希望:1. 所有操作保持在 APP 内;2. 避免能在 APP 内通过百度搜索跳转到任意网站。

本文简要记录一下解决思路和代码实现。

现状分析

WebView 里的长按选择文字,禁用掉对功能无影响。

APP 里除了隐私协议,还有一些其它的 WebView 页面,比如帮助中心等,这些页面是需要能自由跳转超链接的。

隐私协议里的三方 SDK 的隐私协议链接,也是要能点击跳转的,不过可以限制只能跳转一级,在进入三方 SDK 的隐私协议页面后,不让再跳转到其它页面。

解决思路

  1. 禁用掉 WebView 的长按选择文字功能;
  2. 允许白名单域名的页面任意加载;非白名单域名的页面都是通过白名单域名的页面跳转过去的,打开后点击里面的超链接不再响应。

代码实现

private final List<String> domainWhiteList = Arrays.asList("mazhuang.org");

// some code here

// 屏蔽长按弹出的菜单
mWebView.setOnLongClickListener(v -> true);

mWebView.setWebViewClient(new WebViewClient() {
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
        // 非白名单域名网址,只允许加载一级,不允许进一步点里面的链接
        if (view.canGoBack() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            if (Objects.nonNull(request) && request.isForMainFrame()) {
                Uri uri = request.getUrl();
                if (Objects.nonNull(uri) && !TextUtils.isEmpty(uri.getHost())) {
                    String host = uri.getHost();
                    boolean ifWhiteDomain = false;
                    for (String domain : domainWhiteList) {
                        if (host != null && (host.endsWith(domain) || hosts.endsWith("." + domain))) {
                            ifWhiteDomain = true;
                            break;
                        }
                    }
                    if (!ifWhiteDomain) {
                        log.info("非白名单域名网址拦截:{}", uri);
                        return true;
                    }
                }
            }
        }

        return super.shouldOverrideUrlLoading(view, request);
    }
    // some other methods
});

经测试达到了想要的效果。

当我读李娟时我在想些什么

作者 Zhuang Ma
2024年6月7日 00:00

最近李娟的作品很火,我先是在前同事的微博里看到了《遥远的向日葵地》,然后又从现同事的朋友圈里知道了《我的阿勒泰》。

然后就一发不可收拾,找来了她的其它书籍,继续阅读。我羡慕那些能打出精妙比方和写出优美文章的人,坚信他们的内心一定有一个我看不见的精彩世界。所以我企图从她的文字里窥探到写作的奥义,但最终只看见了自己内心的贫瘠。是的,没有对生活细致入微的观察和体验,怎么能写出锦绣文章?莫说写下来,如今好像就连自己过往各个阶段的记忆,都只剩一些碎屑,连不成片。想到这里,我觉得要是坚持把生活和感悟及时记录下来该有多好啊……有些东西丢了就再也找不回来了。

读她的文章的时候,脑海里总能随之浮现出对应的场景,一切都那么平静,所有发生的一切都自然而然,不觉有一点羡慕,有一些神往。但我知道想象总是浪漫的,会淡化苦难。如果是自己在过那样的生活,茫茫草场,深山老林,可能一天到晚见不到一个人影,眼前的一切静止得像一幅图画,只有山风和天上的云朵能带来一点变化,想想都觉得是悠长的寂寞,我真的能乐在其中吗?

看她笔下描述的一切,总是觉得新鲜。不一样的风景,不一样的习俗,不一样的人。这让我想到,有时候我会觉得日子陷入了重复,生活里没有多少新鲜事发生,甚至觉得,如果说上半生,更多是在与贫穷作斗争,那下半生,则更多是对抗内心的贫瘠。

看过一个理论,说随着年龄的增长,人之所以会觉得时间流逝得越来越快,是因为大脑本质上是一个预测机器,它倾向于只关注并记忆那些新奇和令人惊讶的事物。如果一天里有很多新奇的信息涌现,回忆起来,这一天也会很丰富,有很多的时间片段。反之,如果只是单调的重复,则可能没有什么记忆点留存下来。

这个事情重不重要呢?不重要,一个安心的人在哪里都可以过自得其乐的生活。也重要,我们可以通过勇敢地投身于未知,寻找新的挑战,来对抗岁月流逝的匆匆。

现在就说李娟的文字治好了我的精神内耗为时尚早,但我隐约感觉到在阅读的过程中我逐渐在被灌注一种力量,一种提醒自己专注于当下的力量。这股力量是好些以前听过的哲理,和读过的心理学书籍里面企图教会我的,但终究只能自己悟得。

PS:我有时候觉得自己有点矫情,但是想写点什么东西的人,多少是要有一点子矫情在身上的吧。不然,这有什么稀奇的?那有啥好写的?最后就没有什么需要写下来的了。我接受自己有一点这样的矫情。

Java|如何正确地在遍历 List 时删除元素

作者 Zhuang Ma
2024年4月29日 00:00

最近在一个 Android 项目里遇到一个偶现的 java.util.ConcurrentModificationException 异常导致的崩溃,经过排查,导致异常的代码大概是这样的:

private List<XxxListener> listeners;

public void foo() {
    for (XxxListener listener : listeners) {
        listener.doSomething();
    }
}

public class XxxListener {
    public void doSomething() {
        // some code here
        if (...) {
            listeners.remove(this);
        }
    }
}

把函数调用展开一下就等效于:

for (XxxListener listener : listeners) {
    // some code here
    if (...) {
        listeners.remove(listener);
    }
}

这个异常之所以不是必现,是因为 listeners.remove 不是总被执行到。

我先直接说一下正确的写法吧,就是使用迭代器的写法:

Iterator<XxxListener> iterator = listeners.iterator();
while (iterator.hasNext()) {
    XxxListener listener = iterator.next();
    // some code here
    if (...) {
        iterator.remove();
    }
}

然后再进一步分析。

源码分析

先来从源码层面分析下上述 java.util.ConcurrentModificationException 异常是如何抛出的。

写一段简单的测试源码:

List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
list.add("Hi");

for (String str : list) {
    list.remove(str);
}

执行抛出异常:

Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
	at java.util.ArrayList$Itr.next(ArrayList.java:861)

由此可以推测,for (String str : list) 这种写法实际只是一个语法糖,编译器会将其转换为迭代器的写法:

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String str = iterator.next();
    // do something
}

这可以从反编译后的字节码得到验证:

36: invokeinterface #8,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
41: astore_2
42: aload_2
43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
48: ifeq          72
51: aload_2
52: invokeinterface #10,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
57: checkcast     #11                 // class java/lang/String
60: astore_3
61: aload_1
62: aload_3
63: invokeinterface #12,  2           // InterfaceMethod java/util/List.remove:(Ljava/lang/Object;)Z

那么,iterator.next() 里发生了什么导致了异常的抛出呢?ArrayList$Itr 类的源码如下:

 private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;

    public E next() {
        checkForComodification();
        // ...
    }

    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

    // ...
 }

其中 modCount 是 ArrayList 类的成员,表示对 ArrayList 进行增删改的次数。expectedModCount 是 ArrayList$Itr 类的成员,初始值是迭代器创建时 ArrayList 的 modCount 的值。在每次调用 next() 时,都会检查 modCount 是否等于 expectedModCount,如果不等则抛出异常。

那为什么 list.remove 会导致 modCount 的值不等于 expectedModCount,而 iterator.remove 不会呢?

// ArrayList 的 remove 方法
public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                            numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

// ArrayList$Itr 的 remove 方法
public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet);
        // 注意这三行
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

可以看到 ArrayList#removemodCount++,但并不会修改到 Itr 的 expectedModCount——它们当然就不相等了。而 ArrayList$Itr#remove 在先调用了 ArrayList#remove 后,又将 modCount 的最新值赋给了 modCount,这样就保证了 modCountexpectedModCount 的一致性。

同时,ArrayList$Itr#remove 里还有一个 cursor = lastRet,实际上是将迭代器的游标做了修正,前移一位,以实现后续调用 next() 的行为正确。

小结

源码面前,了无秘密。

  • 如果需要在遍历 List 时删除元素,应使用迭代器的写法,即 iterator.remove()
  • 在非遍历场景下,使用 ArrayList#remove 也没什么问题——同理,即使是遍历场景下,使用 ArrayList#remove 后马上 break 也 OK;
  • 如果遍历时做的事情不多,Collection#removeIf 方法也是一个不错的选择(实际也是上述迭代器写法的封装)。

读书|通过免费云盘传书到 Kindle

作者 Zhuang Ma
2024年4月10日 00:00

这是这个系列的第四篇文章,之前写了:

本文介绍如何通过免费云盘传书到 Kindle——这已经成为我目前最喜欢的传书到 Kindle 的方式了。

使用效果

  1. 在电脑/手机等设备上,通过坚果云 APP 或 网站,上传书籍到云盘;

  2. 在 Kindle 上,从云存储下载书籍。

一上一下之间,摆脱了 USB 线缆的束缚,Kindle 和电脑/手机之间也不受地理位置、局域网和时空限制,非常方便。

配置方法

KOReader 官方 Wiki 上对此有说明,链接:https://github.com/koreader/koreader/wiki/%E4%BA%91%E7%9B%98%E5%AD%98%E5%82%A8

大意就是 KOReader 支持 Dropbox、FTP、WebDAV 三种云存储,其中在国内可用又免费的,可以选择坚果云提供的个人免费版 WebDAV 服务。

前提

Kindle 已经越狱,并且已经安装了 KOReader。这部分如有需要,可以参考书伴网站上的相关链接来操作:

坚果云配置

网址:https://www.jianguoyun.com/

注册并登录后,点击网站右上角用户名,选择「账户信息」,然后打开「安全选项」,在「第三方应用管理」里,「添加应用」,名称随意,如「koreader」,然后生成密码,可以得到 服务器地址、账户、应用密码

在「我的文件」里,创建一个文件夹,取名 ebooks。

Kindle 配置

打开 KOReader,在非书籍阅读状态下,调出顶部主菜单,点击「设置」-「云存储」:

点击左上角的 + 号,选择 WebDAV:

填入 WebDAV 信息,服务器显示名称随便取,如 jianguoyun,WebDAV 地址、用户名、密码分别填入上面坚果云生成的信息,注意密码是应用密码,起始目录填入 /ebooks,然后「保存」。

至此,配置其实就已经完成了。

使用

在找到所需的电子书资源后,可以随时通过坚果云网站或 APP 上传到 ebooks 文件夹下。

然后在需要的时候,在 Kindle 上打开 KOReader,非阅读书籍状态调出顶部菜单,点击「设置」-「云存储」,选择 jianguoyun,即可看到 ebooks 文件夹下的书籍,点击下载即可。

坚果云个人免费版服务,每月有 1GB 的上传流量和 1GB 的下载流量,对于传书来说,绰绰有余。有条件也可以升级付费服务,将坚果云用于更多的用途。

工具只是为了提升体验,Kindle 的意义在于阅读,希望大家都能享受到阅读的乐趣。

还有高手?这不得赚个盆满钵满

作者 Zhuang Ma
2024年3月21日 00:00

距离推送 《GitHub 用户福利,符合条件可领取约 1500 元现金》 这篇文章已经过去快一个月了,虽然 STRK 空投到六月份才过期,但我领过以后就没再继续关注了,几乎已经淡忘了此事。

今天查收了一下 Gmail 邮件,却意外发现了这个:

这应该是有人根据 GitHub 上的资格名单,批量获取到有资格领取空投的人的邮箱地址,然后群发的邮件。

按当时一份空投最终能领取到 RMB 1500 元左右计算,如果有一个人愿意让他帮忙领取,就可以收益约 300 左右。超广撒网,应该也有一部分人嫌麻烦,愿意找人帮忙操作的。

如果群发了一万封邮件,有百分之一的人愿意通过他来领取,无本套利三万块到手,是自己能领的一千五的几十倍了,妙啊……活该别人赚大钱 :laughing:

这带来了一点点启发:

  • 当发现好的机会时,应当果断地参与其中;
  • 即使无法直接参与,也可以思路打开一点,看看有没有什么配套/上下游可以做,也许就能从趋势的洪流里,分到一杯羹;
  • 保持对每个机会都进行以上思考和分析。

读书|程序员如何传书到 Kindle

作者 Zhuang Ma
2023年9月16日 00:00

我有一台 2013 年从日亚海淘的 Kindle Paperwhite,至今仍在服役。除了外观上的磨损,其它一切正常,甚至连续航都依旧给力。

从去年亚马逊宣布,将在今年六月停止中国区 Kindle 电子书店的运营后,我一直想写点什么,来记(ji)录(dian)一下这个陪伴我多年的老伙伴,却一直没有动笔。

一年多以后的今天终于开了个头,计划分几个小主题写一写我是如何使用 Kindle 的,包括传书、屏保图片管理、文件管理等等,作为自己的存档和回忆,也希望能帮到一些「后 Kindle 时代」仍然在继续使用它的人。

虽然被戏称泡面盖,但使用电纸书当然是为了阅读,在官方电子书店停止运营后,如何把自己找到的电子书传输到 Kindle 上就成了一个绕不过去的话题。相信一些比较喜欢折腾的老用户都已经熟知各种传书的方式了,比如邮箱推送、USB 传输、亚马逊公众号等,网上相关介绍也非常多,在此不赘述。

本篇记录一下我传书到 Kindle 的「独特」方式——WiFi 传书插件。

这是我自制的一款插件,可以直接通过 WiFi 传输电子书到 Kindle,不需要使用 USB 线,也不依赖其它服务,只要 Kindle 和手机/电脑在同一个局域网内,就可以通过浏览器直接上传电子书到 Kindle。

运行效果

Kindle 端插件运行效果:

手机端上传页面效果:

电脑端上传页面效果:

原理

这个插件的原理是,在 Kindle 上运行一个 HTTP Server,在 8000 端口提供服务,这样局域网内的电脑、手机等设备访问 http://{Kindle 的局域网 IP}:8000,就可以打开一个能上传电子书到 Kindle 的网页。

安装方法

使用这个插件需要先在 Kindle 上安装 KUAL 和 Python3,请先确保已经正确安装它们。它们的安装方法可以参考 https://bookfere.com/post/311.html

插件项目地址:https://github.com/mzlogin/kual-wifi-transfer

  1. 下载项目代码,可以 git clone https://github.com/mzlogin/kual-wifi-transfer.git,也可以直接到项目的 releases 下载 zip 文件;

  2. 将 Kindle 用数据线连接到电脑,把刚刚下载的代码里的 wifi-transfer 文件夹拷贝到 Kindle 的 extensions 目录下(完整路径 /mnt/us/extensions)。

使用方法

  1. 在 Kindle 上开启服务器:

    在 Kindle 上打开 KUAL,就可以在插件列表里看到「WiFi Transfer」菜单项了,点击「Start Server」,Kindle 上将显示 Starting server at <ip:port>

  2. 在电脑或手机上访问第 1 步显示的 <ip:port>,选择电子书文件并上传;

  3. 上传完成后,在 Kindle 上点击「Stop Server」关闭服务器。

小结

以上就是我最喜欢的一种传书方式,它的优点是:

  • 不依赖于 USB 线缆;
  • 不依赖于网络情况——有 WiFi 的时候用 WiFi,没有 WiFi 的时候,手机/电脑开个热点给 Kindle 连上去;
  • 电子书格式不限,Kindle 上能打开的都能直接传输。

另一种我现在比较常用的做法是在 Kindle 的体验版浏览器里打开 r.qq.com,使用微信读书。

Kindle 注定渐行渐远,书籍则继续伴我们同行。

发现一种增加在 GitHub 曝光量的方法,已举报

作者 Zhuang Ma
2023年8月24日 00:00

今天偶然看到一种增加项目和个人在 GitHub 曝光量的方法,但感觉无法赞同这种做法,已经向 GitHub 官方举报。

具体怎么回事呢?我上周在 Vim 插件大佬 tpope 的一个项目提了个 Issue,但一周过去了,大佬也没有回应,我就去他的 GitHub 主页确认他这一周有没有活动记录,看到他最近的提交活动是给 github/copilot.vim 项目——这是 GitHub Copilot 的官方 Vim 插件项目,我也在用,心想这也太巧了吧,于是点进项目主页看了一眼,大佬果然是大佬,竟然是这个插件的主要维护者,不由心生赞叹,同时在 Contributors 列表的上方我还发现了一个以前没太注意到的信息,「Used by」:

Figure 1. copilot.vim’s Used by

好奇心驱使,点进去看看大家能依赖一个 Vim 插件构建一些什么项目:

Figure 2. 依赖 copilot.vim 的项目

列表里的六个项目点进去基本都是空项目或者仅仅作为个人主页的 README 展示的,只有倒数第二个是有实质内容的项目(但最终发现它也没有实质依赖上面的插件)。

它们的共同点是在项目里有一个巨大的 go.mod 文件(初步判断出自 akirataguchi115 之手),里面列出了大量的依赖,足足有六千多行,但实际上都是没有用到的。里面列举的托管在 GitHub 上的「依赖」项目,我随便扫了一眼,有一些熟悉的名字,比如 HelloGitHub996.ICU 等都赫然在列,甚至还包括了我的 awesome-adb,随机打开几个链接看了下,都是 Star 数量 5K+ 的热门项目,而且基本上都不是 package 类项目,不可能被作为依赖包。

Figure 3. go.mod 文件内容

至此恍然大悟:这几千个热门项目的浏览量是比较大,然后它们的首页的「Used by」都会显示上面 Figure 1 里的这几个人,点进去都会看到 Figure 2 里的这几个项目……妙啊!引流效果一定不错!

但是,我对这种做法感到恶心。这「巧妙」地利用了 GitHub 的一个功能,但是扰乱了项目间正常的依赖关系的链接和展示,让真正需要的人筛选和寻找正确的信息更加费劲。

如果想要在热门项目的主页里曝光自己,应该通过正常的方式去做,比如提交 PR、提 Issue、参与 Discussions、真正基于它们做一些实质性的项目等,而不是通过这种「巧妙」的方式。

不然,即使获得了流量和曝光量,也只是遭人唾弃的「现眼包」。

在写这篇文章的同时,我已经向 GitHub 官方举报了这个问题,看看官方如何看待吧。

❌
❌