Skip to content

Blog

This is a simple blog.

  1. sphinx_docs

  2. toyvpn


Toyvpn 详解

Android中可以使用提供了vpn服务,通过VPN builder,可以创建一个网络接口(https://developer.android.google.cn/reference/android/net/VpnService.Builder#establish()),拦截特定或者部分app的流量。

上面的文档提供了比较详细的说明,还有一份服务端+客户端的demo代码。

https://developer.android.google.cn/develop/connectivity/vpn?hl=zh-cn#service https://android.googlesource.com/platform/development/+/master/samples/ToyVpn

服务端部分

There are several ways to play with this program. Here we just give an example for the simplest scenario. Let us say that a Linux box has a public IPv4 address on eth0. Please try the following steps and adjust the parameters when necessary.

Enable IP forwarding

echo 1 > /proc/sys/net/ipv4/ip_forward

作用:允许Linux系统转发IP数据包(作为路由器功能)。 原理:将内核参数 net.ipv4.ip_forward 设为 1,使流量能在不同网络接口间传递。

Pick a range of private addresses and perform NAT over eth0.

iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE

作用:将来自私有网络 10.0.0.0/8 的流量通过公网接口 eth0 进行NAT转换(隐藏客户端真实IP)。 原理:MASQUERADE 规则会动态修改数据包源地址为 eth0 的公网IP,使返回流量能正确路由。

Create a TUN interface.

ip tuntap add dev tun0 mode tun

作用:创建一个名为 tun0 的TUN模式虚拟接口,用于处理VPN隧道的三层(IP层)数据包。 TUN特点:传输原始IP数据包,需用户态程序(如 ToyVpnServer)处理封包逻辑。

Set the addresses and bring up the interface.

ifconfig tun0 10.0.0.1 dstaddr 10.0.0.2 up

作用: 为 tun0 设置本地IP 10.0.0.1,并指定对端(客户端)IP为 10.0.0.2。 up 表示启用接口。 关键参数: dstaddr 定义隧道的目标地址(客户端IP),需与后续服务器命令中的 -a 参数一致。

Create a server on port 8000 with shared secret "test".

./ToyVpnServer tun0 8000 test -m 1400 -a 10.0.0.2 32 -d 8.8.8.8 -r 0.0.0.0 0

参数解析: tun0:绑定到TUN接口。 8000:监听UDP端口8000。 test:共享密钥(明文,无加密)。 -m 1400:设置MTU为1400字节(避免分片)。 -a 10.0.0.2 32:为客户端分配IP 10.0.0.2,子网掩码32位(单地址)。 -d 8.8.8.8:指定客户端DNS服务器为Google DNS。 -r 0.0.0.0 0:添加默认路由(0.0.0.0/0),将所有流量导向VPN隧道。

This program only handles a session at a time. To allow multiple sessions, multiple servers can be created on the same port, but each of them requires its own TUN interface. A short shell script will be sufficient. Since this program is designed for demonstration purpose, it performs neither strong authentication nor encryption. DO NOT USE IT IN PRODUCTION!

该程序一次只能处理一个连接。若需多会话,需为每个客户端创建独立的TUN接口(如 tun1, tun2)并启动多个服务器进程。 无加密:共享密钥 test 以明文传输,易被窃听或篡改。 无身份验证:仅依赖简单密钥,无法防止中间人攻击。 仅限测试:注释明确警告不可用于实际生产环境。

相关代码

// int tun_fd = get_interface("tun0");
// 执行后,系统中会出现 tun0 接口(可通过 ip addr show tun0 查看)。
static int get_interface(char *name)
{

    // **/dev/net/tun**:Linux内核提供的TUN/TAP设备文件,用于创建虚拟网络接口。
    // **O_RDWR**:以读写模式打开设备。
    // **O_NONBLOCK**:设为非阻塞模式(后续读写操作不会阻塞进程)。
    // 返回值:文件描述符 interface,用于后续操作(如读写数据包)。
    // 初始化接口请求结构体 
    int interface = open("/dev/net/tun", O_RDWR | O_NONBLOCK);

    // **ifreq**:用于配置网络接口参数的结构体(定义在 <net/if.h>)
    ifreq ifr;
    memset(&ifr, 0, sizeof(ifr));

    // **IFF_TUN**:创建TUN设备(处理IP层数据包,对应三层网络层)。
    // **IFF_NO_PI**:禁用包头信息(Packet Information),直接传输原始IP数据包。
    // 若未设置此标志,数据包会附加4字节的包头(如协议类型)。
    ifr.ifr_flags = IFF_TUN | IFF_NO_PI;

    // 将用户传入的接口名称(如 "tun0")复制到 ifr.ifr_name。
    // 注意:strncpy 不会自动补全终止符 \0,需确保 name 长度不超过 IFNAMSIZ(通常为16字节)。
    strncpy(ifr.ifr_name, name, sizeof(ifr.ifr_name));

    // **ioctl**:通过 TUNSETIFF 命令将配置应用到TUN设备。
    // 成功:内核会创建名为 name 的TUN接口(如 tun0)。
    // 失败:输出错误信息并终止程序(如权限不足或名称冲突)。
    // 返回文件描述符  
    if (ioctl(interface, TUNSETIFF, &ifr)) {
        perror("Cannot get TUN interface");
        exit(1);
    }

    return interface;
}
// 创建一个UDP服务器,监听指定端口,接收数据包直到收到一个符合特定格式(首字节为0,后面是正确密钥)的数据包,
// 然后与该客户端建立连接,返回socket描述符以供后续通信。
// 这个函数用于在VPN服务器中建立初始的隧道连接,确保客户端提供正确的密钥,并只处理一个客户端。
// port:监听的UDP端口。
// secret:预共享密钥,用于验证客户端。
// 返回值:成功返回连接后的socket文件描述符,失败返回 -1。
static int get_tunnel(char *port, char *secret)
{
    // We use an IPv6 socket to cover both IPv4 and IPv6.
    // 使用 IPv6协议族 (AF_INET6) 创建UDP Socket (SOCK_DGRAM)。
    // 目的:通过IPv6双栈支持,同时监听IPv4和IPv6客户端(需系统支持)。
    // IPv4兼容性依赖系统配置,需确保系统支持IPv4映射地址(sysctl net.ipv6.bindv6only=0)。
    int tunnel = socket(AF_INET6, SOCK_DGRAM, 0);
    int flag = 1;
    // **SO_REUSEADDR**:允许绑定处于 TIME_WAIT 状态的地址,快速重启服务。
    // **IPV6_V6ONLY=0**:允许Socket接收IPv4映射的IPv6地址(如 ::ffff:192.168.1.1)。
    setsockopt(tunnel, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
    flag = 0;
    setsockopt(tunnel, IPPROTO_IPV6, IPV6_V6ONLY, &flag, sizeof(flag));

    // Accept packets received on any local address.
    sockaddr_in6 addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin6_family = AF_INET6;
    addr.sin6_port = htons(atoi(port));

    // Call bind(2) in a loop since Linux does not have SO_REUSEPORT.
    // 循环绑定:若端口被占用 (EADDRINUSE),等待0.1秒后重试,直至成功。
    while (bind(tunnel, (sockaddr *)&addr, sizeof(addr))) {
        if (errno != EADDRINUSE) {
            return -1;
        }
        usleep(100000);
    }

    // Receive packets till the secret matches.
    char packet[1024];
    socklen_t addrlen;
    // 数据包格式:首个字节为 0,后续为密钥字符串(如 \0test)。
    // 验证逻辑:
    // 若首字节非 0 或密钥不匹配,继续接收新数据包。
    // 密钥匹配后退出循环。
    do {
        addrlen = sizeof(addr);
        int n = recvfrom(tunnel, packet, sizeof(packet), 0,
                (sockaddr *)&addr, &addrlen);
        if (n <= 0) {
            return -1;
        }
        packet[n] = 0;
    } while (packet[0] != 0 || strcmp(secret, &packet[1]));

    // Connect to the client as we only handle one client at a time.
    // 目的:将UDP Socket与客户端地址绑定,后续通信可使用 send/recv 替代 sendto/recvfrom。
    // 影响:该Socket仅处理来自此客户端的流量,实现单会话独占。
    connect(tunnel, (sockaddr *)&addr, addrlen);
    return tunnel;
}

// Get TUN interface.
// TUN设备用于与内核网络栈交互(如接收/发送IP层数据包)
int interface = get_interface(argv[1]);

// Wait for a tunnel.
int tunnel;
// get_tunnel 创建UDP服务端,监听指定端口,验证客户端密钥,返回已连接的socket描述符 tunnel。
// 单客户端限制:每个循环仅处理一个客户端,断开后才接受下一个连接。
while ((tunnel = get_tunnel(argv[2], argv[3])) != -1) {
    printf("%s: Here comes a new tunnel\n", argv[1]);

    // On UN*X, there are many ways to deal with multiple file
    // descriptors, such as poll(2), select(2), epoll(7) on Linux,
    // kqueue(2) on FreeBSD, pthread(3), or even fork(2). Here we
    // mimic everything from the client, so their source code can
    // be easily compared side by side.

    // Put the tunnel into non-blocking mode.
    // 确保 send/recv 不会阻塞进程,允许轮询多个文件描述符(尽管此处未实现多路复用)。
    fcntl(tunnel, F_SETFL, O_NONBLOCK);

    // Send the parameters several times in case of packet loss.
    // 向客户端重复发送3次 parameters。
    // 目的:应对UDP丢包,提高参数传递可靠性。
    for (int i = 0; i < 3; ++i) {
        send(tunnel, parameters, sizeof(parameters), MSG_NOSIGNAL);
    }

    // Allocate the buffer for a single packet.
    // // 缓冲区(最大支持32KB数据包)
    char packet[32767];

    // We use a timer to determine the status of the tunnel. It
    // works on both sides. A positive value means sending, and
    // any other means receiving. We start with receiving.
     // 状态计时器(正=发送,负=接收)
    int timer = 0;

    // We keep forwarding packets till something goes wrong.
    // 优势:单线程处理双向流量,代码简洁。
    // 劣势:高负载下频繁休眠可能导致延迟;未使用 poll/epoll 等多路复用机制,效率较低。
    while (true) {
        // Assume that we did not make any progress in this iteration.
        bool idle = true;

        // Read the outgoing packet from the input stream.
        // TUN → 隧道:读取内核发出的IP数据包(如来自客户端的流量),通过UDP隧道发送给客户端。
        int length = read(interface, packet, sizeof(packet));
        if (length > 0) {
            // Write the outgoing packet to the tunnel.
            send(tunnel, packet, length, MSG_NOSIGNAL);

            // There might be more outgoing packets.
            idle = false;

            // If we were receiving, switch to sending.
            if (timer < 1) {
                timer = 1;
            }
        }

        // Read the incoming packet from the tunnel.
        // 隧道 → TUN:接收客户端发来的数据包,写入TUN接口(最终进入内核网络栈)。
        length = recv(tunnel, packet, sizeof(packet), 0);
        if (length == 0) {
            break;
        }
        if (length > 0) {
            // Ignore control messages, which start with zero.
            if (packet[0] != 0) {
                // Write the incoming packet to the output stream.
                write(interface, packet, length);
            }

            // There might be more incoming packets.
            idle = false;

            // If we were sending, switch to receiving.
            if (timer > 0) {
                timer = 0;
            }
        }

        // If we are idle or waiting for the network, sleep for a
        // fraction of time to avoid busy looping.
        if (idle) {
            usleep(100000);  // 休眠100ms避免忙等待

            // Increase the timer. This is inaccurate but good enough,
            // since everything is operated in non-blocking mode.
            timer += (timer > 0) ? 100 : -100;

            // We are receiving for a long time but not sending.
            // Can you figure out why we use a different value? :)
            // 长时间未收到响应(接收超时)
            // 心跳机制:接收超时后发送控制包(首字节 0),维持NAT映射或检测存活。
            // 超时阈值:-16000(约16秒)和 20000(约20秒)基于累计的休眠时间估算。

            // 发送状态(timer > 0):累计正值,超时断开。
            // 接收状态(timer ≤ 0):累计负值,超时发送心跳包。
            // 目的:检测单向通信故障(如客户端掉线但未发送FIN)。
            if (timer < -16000) {
                // Send empty control messages.
                packet[0] = 0;
                for (int i = 0; i < 3; ++i) {
                     // 发送空控制包(心跳)
                    send(tunnel, packet, 1, MSG_NOSIGNAL);
                }

                // Switch to sending.
                timer = 1;
            }

            // We are sending for a long time but not receiving.
            if (timer > 20000) {
                break;
            }
        }
    }
    printf("%s: The tunnel is broken\n", argv[1]);
    close(tunnel);
}

客户端部分

https://developer.android.google.cn/develop/connectivity/vpn?hl=zh-cn#service

VpnService.Builder.establish

在Android开发中,VpnService.Builder.establish() 方法返回的文件描述符(File Descriptor, fd)是VPN功能的核心,它允许应用拦截和处理设备的网络流量。该fd代表一个TUN虚拟网络接口,用于在系统和应用之间传输原始IP数据包。当VPN激活时,系统将符合路由规则的流量重定向到此接口,应用通过读取fd获取设备发出的数据包,处理后转发到目标服务器;同时将服务器返回的数据写入fd,系统会将其传递给接收应用。

出站流量:应用从fd读取的是设备发送的原始IP包(如TCP/UDP/ICMP)。 入站流量:应用构造响应IP包并写入fd,系统会将其视为来自外部网络的数据。

VpnService 的 fd 操作的是 IP 层 的原始数据包(基于 TUN 设备),而非传输层(TCP/UDP)。这意味着:

读取时:从 fd 读取的是一个完整的 IP 数据包(IPv4 或 IPv6),其载荷可能包含 TCP、UDP、ICMP 或其他协议的数据。 写入时:必须构造一个完整的 IP 数据包(包含正确的 IP 头和校验和),系统才会将其视为合法的入站流量。

IP 头部:包含协议类型(Protocol 字段,如 6 表示 TCP,17 表示 UDP)。 传输层数据:在 IP 头部之后,例如 TCP 头部及其载荷。

读取流程:

从 fd 读取二进制数据,得到一个原始 IP 数据包。 解析 IP 头部的 Protocol 字段,确定是 TCP(6)、UDP(17)还是其他协议。 根据协议类型,继续解析后续的 TCP/UDP 头部(如端口号、校验和等)。

写入流程:

构造一个完整的 IP 数据包(需手动设置 IP 头部字段)。 填充传输层数据(如 TCP/UDP 头部及其载荷)。 计算 IP 头部和传输层头部的校验和。 将二进制数据写入 fd。

addAllowedApplication

VpnService.Builder builder = new VpnService.Builder();
builder.addAllowedApplication("com.example.app1"); // 指定生效的包名
builder.addAllowedApplication("com.example.app2");
ParcelFileDescriptor fd = builder.establish();

生效规则为白名单机制:调用 addAllowedApplication 后,只有指定的应用流量会经过 VPN。若不调用此方法,默认所有应用的流量都会经过 VPN(全局 VPN)。

Android 系统为每个应用分配唯一的 UID。addAllowedApplication 会根据包名查询应用的 UID,并在内核网络规则中标记这些 UID 的流量。流量重定向通过 iptables 规则实现(如 fwmark 标记)。

即使未调用 addAllowedApplication,也可以通过 addRoute 控制哪些 IP 范围的流量经过 VPN。但 addAllowedApplication 优先级更高:如果指定了应用白名单,即使 IP 符合路由规则,非白名单应用的流量也不会经过 VPN。

注意事项

必须在 establish() 前调用:配置需在建立 VPN 连接前完成。 系统应用限制:无法控制系统级应用(如 android 包名)的流量。 动态生效:若应用在 VPN 连接后安装,需重新建立 VPN 连接才能生效。

相关代码

// 优化点:
// 多线程处理:分离读写线程以提高吞吐量。
// 缓冲区池:复用 ByteBuffer 减少内存分配开销。
// Create a DatagramChannel as the VPN tunnel.
try (DatagramChannel tunnel = DatagramChannel.open()) {

    // Protect the tunnel before connecting to avoid loopback.
    // **mService.protect()**:确保 UDP socket 绕过 VPN 自身,避免数据循环。
    if (!mService.protect(tunnel.socket())) {
        throw new IllegalStateException("Cannot protect the tunnel");
    }

    // Connect to the server.
    // **tunnel.connect()**:连接到 VPN 服务器的 UDP 端口。
    tunnel.connect(server);

    // For simplicity, we use the same thread for both reading and
    // writing. Here we put the tunnel into non-blocking mode.
    tunnel.configureBlocking(false);

    // Authenticate and configure the virtual network interface.
    iface = handshake(tunnel);

    // Now we are connected. Set the flag.
    connected = true;

    // **FileInputStream**:从虚拟接口读取本地应用待发送的数据包。
    // **FileOutputStream**:将接收到的数据包写入虚拟接口,供本地应用处理。
    // **ByteBuffer**:缓冲区,用于临时存储单个数据包。
    // Packets to be sent are queued in this input stream.
    FileInputStream in = new FileInputStream(iface.getFileDescriptor());

    // Packets received need to be written to this output stream.
    FileOutputStream out = new FileOutputStream(iface.getFileDescriptor());

    // Allocate the buffer for a single packet.
    ByteBuffer packet = ByteBuffer.allocate(MAX_PACKET_SIZE);

    // Timeouts:
    //   - when data has not been sent in a while, send empty keepalive messages.
    //   - when data has not been received in a while, assume the connection is broken.
    long lastSendTime = System.currentTimeMillis();
    long lastReceiveTime = System.currentTimeMillis();

    // We keep forwarding packets till something goes wrong.
    while (true) {
        // Assume that we did not make any progress in this iteration.
        boolean idle = true;

        // Read the outgoing packet from the input stream.
        // // 处理发送数据(本地应用 -> 服务器)
        int length = in.read(packet.array());
        if (length > 0) {
            // Write the outgoing packet to the tunnel.
            packet.limit(length);
            tunnel.write(packet);
            packet.clear();

            // There might be more outgoing packets.
            idle = false;
            lastReceiveTime = System.currentTimeMillis();
        }

        // Read the incoming packet from the tunnel.
        // 处理接收数据(服务器 -> 本地应用)
        length = tunnel.read(packet);
        if (length > 0) {
            // Ignore control messages, which start with zero.
            // 忽略控制消息(首字节为0)
            if (packet.get(0) != 0) {
                // Write the incoming packet to the output stream.
                // 转发至本地应用
                out.write(packet.array(), 0, length);
            }
            packet.clear();

            // There might be more incoming packets.
            idle = false;
            lastSendTime = System.currentTimeMillis();
        }

        // If we are idle or waiting for the network, sleep for a
        // fraction of time to avoid busy looping.
        if (idle) {
            Thread.sleep(IDLE_INTERVAL_MS);
            final long timeNow = System.currentTimeMillis();

            // 保活包:定期发送首字节为 0 的空包,防止 NAT 超时断开。
            // 超时断开:超过 RECEIVE_TIMEOUT_MS 未收到数据,认为连接已中断。
            if (lastSendTime + KEEPALIVE_INTERVAL_MS <= timeNow) {
                // We are receiving for a long time but not sending.
                // Send empty control messages.
                // 长时间未发送数据:发送空保活包
                packet.put((byte) 0).limit(1);
                for (int i = 0; i < 3; ++i) {
                    packet.position(0);
                    tunnel.write(packet);
                }
                packet.clear();
                lastSendTime = timeNow;
            // 长时间未接收数据:断开连接
            } else if (lastReceiveTime + RECEIVE_TIMEOUT_MS <= timeNow) {
                // We are sending for a long time but not receiving.
                throw new IllegalStateException("Timed out");
            }
        }
    }
} catch (SocketException e) {
    Log.e(getTag(), "Cannot use socket", e);
} finally {
    if (iface != null) {
        try {
            iface.close();
        } catch (IOException e) {
            Log.e(getTag(), "Unable to close interface", e);
        }
    }
}

小结

总的来说,linux和android上都有接口,创建/使用虚拟网卡,提供了拦截/发送 IP数据包的能力。toyvpn中基于udp数据包,把android中的IP数据包转发到linux上。

实际体验下来,很不稳定,终归是toy。

Sphinx doc

上一篇博客:用sphinx-doc整理文档
回头看,上一篇博客已经是18年的事情了。最近我又开始维护起18年的项目了。最近策划同事提了一些需求。我又改进了一波,所以有本文。

sphinx支持导出pdf

sphinx本身是支持导出pdf的,命令如下:

make clean latexpdf

支持中文的话,需要在 conf.py 增加如下信息:

language = 'zh_CN'
# https://sphinx-rtd-trial.readthedocs.io/en/1.0.8/config.html
# support chinese
latex_elements = {
    'preamble': r'''
    \hypersetup{unicode=true}
    \usepackage{CJKutf8}
    \DeclareUnicodeCharacter{00A0}{\nobreakspace}
    \DeclareUnicodeCharacter{2203}{\ensuremath{\exists}}
    \DeclareUnicodeCharacter{2200}{\ensuremath{\forall}}
    \DeclareUnicodeCharacter{2286}{\ensuremath{\subseteq}}
    \DeclareUnicodeCharacter{2713}{x}
    \DeclareUnicodeCharacter{27FA}{\ensuremath{\Longleftrightarrow}}
    \DeclareUnicodeCharacter{221A}{\ensuremath{\sqrt{}}}
    \DeclareUnicodeCharacter{221B}{\ensuremath{\sqrt[3]{}}}
    \DeclareUnicodeCharacter{2295}{\ensuremath{\oplus}}
    \DeclareUnicodeCharacter{2297}{\ensuremath{\otimes}}
    \begin{CJK}{UTF8}{gbsn}
    \AtEndDocument{\end{CJK}}
    ''',
    'extraclassoptions': 'openany,oneside',
}

需要安装如下几个软件:

apt-get install -y texlive-fonts-recommended texlive-fonts-extra
apt-get install -y texlive-latex-extra latexmk latex-cjk-all 

PS:这几个软件都有点大,增加的时候注意看看是否需要。

sphinx支持中文搜索

需要安装这个分词软件

pip install jieba

需要在 conf.py 增加如下信息:

try:
    import jieba
    html_search_options = {
        'type': 'test',
        'language': 'zh',
        'tokenizer': 'jieba.sphinx_zh'
    }
except:
    pass

在 Sphinx 搜索引擎中配置 'tokenizer': 'jieba.sphinx_zh' 以支持中文搜索,其核心原理是通过集成 中文分词技术 和 搜索引擎的索引机制,将中文文本拆解为有意义的词语单元,从而实现对中文内容的精准检索。以下是具体实现原理的分步解释:

  1. 中文分词的挑战

中文文本没有像英文那样的空格分隔符,因此需要借助分词工具将连续的字序列切分为独立的词语。例如:

原始文本:"我爱自然语言处理" 分词结果:["我", "爱", "自然语言处理"] 或 ["我", "爱", "自然", "语言", "处理"](取决于分词模式) 分词质量直接影响搜索的准确性和召回率。错误的分词(如 "自然语" 和 "言处理")会导致搜索失败。

  1. Jieba 分词器的角色

Jieba 是一个高效的中文分词工具,提供以下能力:

精确模式:将句子最精确地切分(适合文本分析)。 全模式:扫描所有可能的词语组合(速度快,可能冗余)。 搜索引擎模式:在精确模式基础上,对长词再次切分(适合检索场景)。 在 Sphinx 中,jieba.sphinx_zh 模块是专门为 Sphinx 设计的适配接口,使其能够调用 Jieba 的分词功能。

  1. Sphinx 的索引和搜索流程

配置 tokenizer: jieba.sphinx_zh 后,Sphinx 的工作流程如下:

索引阶段(Indexing) 文本预处理:读取文档中的中文文本。 调用 Jieba 分词:使用 Jieba 将文本切分为词语列表。例如: 输入:"清华大学位于北京" 分词结果:["清华大学", "位于", "北京"] 构建倒排索引:将分词后的词语作为索引项(terms),记录它们在文档中的位置和频率,形成倒排索引结构。 搜索阶段(Searching) 用户输入查询:例如 "北京 大学"。 查询分词:Jieba 将查询词切分为 ["北京", "大学"]。 匹配倒排索引:Sphinx 查找包含这些词语的文档,计算相关性并返回结果。

  1. 关键技术与优化

词典机制:Jieba 内置核心词典(如 dict.txt),包含常见词语及其词频,确保基础分词的准确性。用户也可扩展自定义词典(如添加专业术语 "自然语言处理")。 算法优化: 基于前缀词典的贪婪匹配:最大化匹配长词(如优先匹配 "清华大学" 而非 "清华" + "大学")。 隐马尔可夫模型(HMM):识别未登录词(如人名、机构名)。 停用词过滤:Sphinx 可配置忽略常见无意义词(如 "的"、"了"),减少索引冗余。 词性标注(可选):通过 Jieba 的词性标注功能,实现更细粒度的搜索过滤(如仅搜索名词)。

  1. 为何需要显式配置 Tokenizer?

Sphinx 默认使用基于空格和标点的简单分词(适合英文),无法处理中文连续文本。通过替换为 jieba.sphinx_zh,Sphinx 的底层分词逻辑被重定向到 Jieba 的接口,从而支持中文语义单元的分词。

  1. 实际依赖和扩展

安装 Jieba:需通过 pip install jieba 安装库,并确保 Sphinx 能调用 Python 模块。 自定义词典:可通过 jieba.load_userdict("custom_dict.txt") 添加领域专有词汇。 性能调优:调整 Jieba 的缓存机制和并行分词模式,以提升索引速度。

总的来说,通过集成 Jieba 分词器,Sphinx 将中文文本转化为有意义的词语单元,解决了中文全文检索的核心难题。这一配置直接影响了索引构建和查询处理的底层逻辑,使 Sphinx 能够像处理英文一样高效地支持中文搜索,核心依赖分词准确性和搜索引擎的倒排索引技术

sphinx支持自定义样式/JS

需要在 conf.py 增加如下信息:

html_static_path = ['_static']
html_js_files = ['yeshen.js','yeshen_1.js']
html_css_files = ['yeshen.css']

创建 _static 目录,然后在其中增加 yeshen.js yeshen.css yeshen_1.js 等几个文件 这几个文件会在生成的index.html中引用。

目录说明:

conf.py
_static
    |-- yeshen.js
    |-- yeshen.css
    |__ yeshen_1.js

sphinx支持自定义模版

  1. 创建模板目录

在项目根目录下创建 _templates 文件夹:

mkdir source/_templates
  1. 配置模板路径

在  conf.py 中指定模板路径:

templates_path = ['_templates']
  1. 覆盖现有模板

查找目标模板:确定你要修改的模板文件(如 layout.html, footer.html 等)。参考你所使用的主题的模板结构。 创建同名文件:在 _templates 下创建同名文件,Sphinx将优先使用你的模板。 示例:修改页脚(覆盖 footer.html)

<!-- source/_templates/footer.html -->
<div class="footer">
    © 2025 @yeshen.org | 自定义页脚内容
</div>
  1. 扩展现有模板(继承并修改) 使用Jinja2的 {% extends %} 继承原模板,然后覆盖特定块。

示例:在侧边栏添加内容

<!-- source/_templates/sidebar.html -->
{% extends "!sidebar.html" %}

{% block sidebarlogo %}
    {{ super() }}  <!-- 保留原有内容 -->
    <div class="custom-sidebar-section">
        你的自定义内容
    </div>
{% endblock %}
  1. 创建全新模板

在 _templates 中新建文件,并通过 .. template:: 指令在RST中引用。

  1. 使用自定义模板

HTML主题:确保 conf.py 中设置了正确的主题(如 html_theme = 'alabaster')。 构建文档:

sphinx-build -b html source build

常用模板文件 layout.html: 整体布局 header.html: 页眉 footer.html: 页脚 sidebar.html: 侧边栏 page.html: 单页内容 提示 使用 sphinx-build -b html source build 重新生成文档以查看更改。 查看主题的原始模板文件(如经典主题模板)获取可覆盖的块名。 通过以上步骤,你可以灵活定制Sphinx文档的样式和结构

sphinx支持单页面

多页面的场景下,切换导航的时候,页面会跳动,体验不是很好,sphinx支持编译单页面,可以这样做:

make clean singlehtml

但是观察单页面输出的场景,搜索栏不见了。

~~怎么添加搜索栏还要进一步尝试,本文就不展开了~~

尾声

如果还有下一篇博客的话,应该是怎么定制自己的模版了。:)

have fun~