我目前使用 Camelot 从 PDF 文件中提取表格。很多情况下,一个表格会跨越多页,或者包含空白行。这两种情况都会导致 Camelot 生成两个或多个表格(具体取决于空白行的数量或表格跨越的页数)。我想知道在 Camelot 生成多个表格后,是否有办法将这些单独的表格合并成一个表格?或者,是否有办法让 Camelot 忽略分页符和/或空白行?

以下脚本将从给定的 PDF 文件中提取所有表格,并将跨越多页的表格合并。结果将以 CSV 文件格式保存,文件名格式为 page-x-table-y.csv 其中 page-x-table-y 分别代表表格所在的页码和表格在该页码上的索引(因为一页上可能包含多个表格)。对于合并的表格,文件名中的 x 代表表格起始页码。

请注意,此脚本仅提取带有边框的表格,不适用于其他类型的表格——这意味着它使用的是 Camelot 的 lattice 模式而非 stream 模式 。因此,如有必要,您可能需要根据自身需求进行调整。

如果 PDF 文件中没有表格,则不会生成任何文件!

将以下代码保存到名为 pdf-extract-tables.py 的文件中,然后像这样调用它

python /path/to/pdf-extract-tables.py -p /path/to/file.pdf -t 15

实现神奇效果的代码:

import argparse
import warnings
import camelot
import csv
import os

def get_continued_tables(tables, threshold):

continued_tables = {}
previous_table = False
group_counter = 0

# typical height of a pdf is 842 points and bottom margins are anywhere between 56 and 85 points
# therefore, accounting for margins, 792
page_height = 792

# iterate over the tables
for i, table in enumerate(tables):

# if a previous table exists (remember, we start with this as false)
# and the previous table was on the previous page
# and the number of columns of both tables is the same
if previous_table and table.page == previous_table.page + 1 and len(table.cols) == len(previous_table.cols):

# get the bottom coordinate of the previous table
# note that for pdfs the origin (0, 0) typically starts from the bottom-left corner of the page,
# with the y-coordinate increasing as you move upwards
# this is why for {x0, y0, x1, y1} we need the y0 as the bottom
previous_table_bottom = previous_table._bbox[1]

# get the top coordinate of the current table
# for {x0, y0, x1, y1} we need the y1 as the top
current_table_top = table._bbox[3]

# if the previous table ends in the last 15% of the page and the current table starts in the first 15% of the page
if previous_table_bottom < (threshold / 100) * page_height and current_table_top > (1 - threshold / 100) * page_height:

# if we don't have started this group of tables
if (continued_tables.get(group_counter) is None):

# start by adding the first table
continued_tables[group_counter] = [previous_table]

# add any of the sunsequent tables to the group
continued_tables[group_counter].append(table)

# if this is not a continuation of the previous table
else:

# increment the group number
group_counter += 1;

# if this is not a continuation of the previous table
else:

# increment the group number
group_counter += 1;

# the current table becomes the previous table for the next iteration
previous_table = table

# transform the dictionary into an array of arrays
continued_tables = [value for value in continued_tables.values()]

# return the combined tables
return continued_tables

def main():

class NewlineFormatter(argparse.RawDescriptionHelpFormatter):
def _split_lines(self, text, width):
return text.splitlines()

# create argument parser
parser = argparse.ArgumentParser(description = 'Returns an array of tables that should be grouped, also as an array.\nThe tables that should be grouped represent tables that span over multiple pages.', formatter_class = NewlineFormatter)
parser.add_argument('--path', '-p', type = str, metavar = '', required = True, help = 'path to the PDF file containing tables')
parser.add_argument('--threshold', '-t', type = int, metavar = '', default = 15, help = 'if the table on previous page ends in the last x%% of the page and\nthe table on the next page starts in the first x%% of the page,\ntables will be considered as spanning over those pages.\nDefault is 15')

# parse command-line arguments
args = parser.parse_args()

# suppress warning about no tables found
warnings.filterwarnings('ignore', message = 'No tables found on page-*')

# extract tables
tables = camelot.read_pdf(args.path, flavor = 'lattice', pages = 'all')

# get continued tables
continued_tables = get_continued_tables(tables, args.threshold);

# get the name of the PDF file we are processing (without the extension)
pdf_file_name = os.path.basename(args.path)

# the path where we're writing the file to
# (same place where we processed the file from)
file_path = os.path.dirname(args.path)

written = []

# the number of rows to be written at once to the CSV
# (writing rows one by one is less efficient)
batch_size = 1000

# iterate over found tables
for i, table in enumerate(tables):

# if table was already written as part of a group
if table in written: continue

# check if the current table is a continued table
is_continued = any(table in sublist for sublist in continued_tables)

# define the filename for the CSV file (the PDF file's name suffixed with the page where the table was found and the table's index on that page)
# (the naming is the same as what camelot does when called from the CLI)
file_name = f"{pdf_file_name}-page-{table.parsing_report['page']}-table-{table.parsing_report['order']}.csv"

# open the CSV file for writing
with open(file_path + '/' + file_name, 'w', newline = '') as csv_file:

# create a CSV writer
csv_writer = csv.writer(csv_file)

batch = []

# iterate over the rows in the table
for row in table.data:

# add row to batch
batch.append(row)

# if we gathered enough rows, or if we're at the last row
if len(batch) >= batch_size or row == table.data[-1]:

# write batch to the CSV file
csv_writer.writerows(batch)

# clear the batch
batch = []

# if there are any records left to be written
# write batch to the CSV file
if batch: csv_writer.writerows(batch)

# if the current table is a continued table, write all subsequent continued tables to the same CSV file
if is_continued:

# get the index of the group in "continued_tables" associated with the current table
group_index = next(index for index, sublist in enumerate(continued_tables) if table in sublist)

# iterate over the tables in said group and append their data to the same CSV file
for continued_table in continued_tables[group_index]:

# skip writing the current table as it's already written
if continued_table == table or continued_table in written: continue

# write the data of the continued table to the same CSV file
with open(file_path + '/' + file_name, 'a', newline = '') as csv_file:

# create a CSV writer
csv_writer = csv.writer(csv_file)

batch = []

# iterate over the rows in the table
for row in continued_table.data:

# add row to batch
batch.append(row)

# if we gathered enough rows, or if we're at the last row
if len(batch) >= batch_size or row == table.data[-1]:

# write batch to the CSV file
csv_writer.writerows(batch)

# clear the batch
batch = []

# if there are any records left to be written
# write batch to the CSV file
if batch: csv_writer.writerows(batch)

# keep track of written tables so that are not written again in the main iteration
written.append(continued_table)

if __name__ == "__main__":
main()

1. 问题现象

在 Windows 系统中启动应用(如 Tomcat、Node.js、Spring Boot 等)时,系统抛出 Address already in use (端口已被占用) 错误。然而,使用常规命令 netstat -ano | findstr ":8080" 查询时,却**没有任何进程(PID)**在监听该端口。

2. 根本原因(Why)

该现象由 Windows Hyper-V / WSL2 / Docker DesktopWinNAT(网络地址转换服务) 的底层机制冲突导致:

  1. 系统保留机制:WinNAT 服务在系统启动时,会随机、成批地向系统内核申请保留多段 TCP 端口(通常每组 100 个),用于虚拟机或容器的内部通信。
  2. 默认范围过低:Windows 默认的动态端口起始位置偏低,导致系统在随机霸占端口时,经常误将开发者常用的核心端口(如 8080, 3000, 9000)锁死。
  3. 特征:这些被系统保留的端口在 netstat 中对普通应用显示为“空闲”,但普通应用无权绑定。且每次重启电脑后,被保留的端口区间会随机发生变化

3. 排查方法

打开具有 管理员权限 的命令提示符(CMD)或 PowerShell,运行以下命令查看系统当前排他的端口范围:

netsh int ipv4 show excludedportrange protocol=tcp

结果分析:
若输出的表格中,目标端口(例如 8080)落在了某一组 开始端口结束端口 的区间内,即可证实该端口已被系统强制保留。

4. 彻底解决方案(One-Time Fix)

为了彻底解决并防止系统未来继续随机侵占低位常用开发端口,最有效的办法是将 Windows 的动态端口起始位置移至国际标准高位段(49152)

请在 管理员权限 的终端中,严格按照以下步骤执行:

步骤一:暂停网络转换服务

因为部分端口当前正被系统占用,直接修改可能会触发“拒绝访问”或“文件正被使用”的错误。需要先暂停 WinNAT 服务:

net stop winnat

步骤二:重置动态端口范围

将 TCP 和 UDP 的动态端口起点强制修改为 49152(共分配 16383 个端口)。修改后,系统服务将只在 49152~65535 之间随机保留端口,永远不会再触碰 8080 等低位端口。

netsh int ipv4 set dynamicport tcp start=49152 num=16383
netsh int ipv4 set dynamicport udp start=49152 num=16383

步骤三:重新启动网络服务

net start winnat

步骤四:重启计算机

执行完上述操作后,必须重启电脑使配置在系统内核中彻底生效。

5. 验证结果

电脑重启后,再次以管理员身份运行排查命令:

netsh int ipv4 show excludedportrange protocol=tcp

此时会发现诸如 8023-8122 这一类处于低位段(10000以下)的保留区间已全部移至 49152 之后,8080 端口成功释放,应用可正常启动。

一、 项目背景与核心痛点

服务器经常遭受来自全球恶意 IP 的 SSH 暴力破解及 Web 扫描(如本案中记录的越南 IP 203.113.174.95中国香港 IP 101.36.122.139)。

  • 核心业务诉求:精细化控制服务器端口(SSH、HTTP、HTTPS 等),只允许中国大陆境内的 IP 建立连接,非中国大陆 IP 一律在系统最外层直接丢弃(DROP)。
  • 技术底座环境:阿里云 ECS(CentOS 8+ / Rocky Linux 等现代发行版),物理层由 nftables(作为 Firewalld 的现代编译后端)承载,上层配合 Fail2ban 联动监控。
阅读全文 »

新建 /etc/yum.repos.d/mysql-community.repo

/etc/yum.repos.d/mysql-community.repo
[mysql-connectors-community]
name=MySQL Connectors Community
baseurl=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql-connectors-community-el9-$basearch/
enabled=1
gpgcheck=1
gpgkey=https://repo.mysql.com/RPM-GPG-KEY-mysql
https://repo.mysql.com/RPM-GPG-KEY-mysql-2022
https://repo.mysql.com/RPM-GPG-KEY-mysql-2023

[mysql-tools-community]
name=MySQL Tools Community
baseurl=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql-tools-community-el9-$basearch/
enabled=1
gpgcheck=1
gpgkey=https://repo.mysql.com/RPM-GPG-KEY-mysql
https://repo.mysql.com/RPM-GPG-KEY-mysql-2022
https://repo.mysql.com/RPM-GPG-KEY-mysql-2023

[mysql-8.4-community]
name=MySQL 8.4 Community Server
baseurl=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql-8.4-community-el9-$basearch/
enabled=1
gpgcheck=1
gpgkey=https://repo.mysql.com/RPM-GPG-KEY-mysql
https://repo.mysql.com/RPM-GPG-KEY-mysql-2022
https://repo.mysql.com/RPM-GPG-KEY-mysql-2023
dnf cache all
dnf makecache
dnf install -y mysql-community-server

在微调过程中,max_seq_length 是一个至关重要的参数,它决定了模型单次处理文本的“视野”范围。以下是它的具体作用及对显存、性能和效果的影响:

1. 核心作用:定义窗口大小

max_seq_length 规定了输入模型(包括 System Prompt + User Prompt + Assistant Output)的总 Token 数量上限。

  • 截断机制:如果你的输入文本超过这个长度,超出部分会被直接丢弃(Truncation)。
  • 填充逻辑:对于短于此长度的文本,训练器会进行填充(Padding),使数据对齐以方便 GPU 进行批处理。

2. 对显存 (VRAM) 的决定性影响

显存消耗与序列长度并非线性关系,而是呈平方级增加(因为注意力机制计算量为 $O(n^2)$)。

  • 显存占用max_seq_length 从 2048 增加到 4096,显存开销会大幅跳升。
  • Unsloth 优化:Unsloth 通过 Flash Attention 等技术降低了显存增长的斜率,使得在 T4 这种 16GB 显存的显卡上能够跑更长的上下文。

3. 性能与速度的权衡

  • 训练速度:长度越短,单步训练(Step)的速度越快。如果你只需要提取简短的合同摘要,将 4096 改为 1024 能节省大量时间。
  • 吞吐量:较小的长度允许设置更大的 per_device_train_batch_size,从而提升 GPU 利用率。

4. 对 IDP(要素提取)任务的影响点

在你的文档处理场景中,设置不当会导致以下问题:

  • 提取不全:如果你处理的是长达 10 页的法律文书(约 6000 Token),但设置 max_seq_length = 2048,模型将永远看不见文档后半部分的信息,导致提取出的 JSON 字段为 null 或出错。
  • 格式损毁:如果 assistant 的 JSON 输出刚好在末尾被截断,会导致导出的 JSONL 文件损坏,影响微调质量。

💡 针对你的 Qwen3-4B 设置建议

  1. 通常推荐 2048:这能覆盖绝大多数简历、发票和短篇法律合同。
  2. 显存压力:如果在 Colab T4 上出现 Out of Memory,优先降低 max_seq_length(例如降到 1024)或开启 use_gradient_checkpointing
  3. 动态匹配:检查你生成的 data.jsonl 中最长的一行有多少 Token。建议设置略大于该长度的值,以保证信息完整。

简单来说,长度要够用,但不能浪费——够用保证准确,不浪费保证训练效率。


在 LLM(大语言模型)和微调的语境下,max_seq_length 既不是字节(Byte),也不是字符(Character),而是 Token(标记/词元)

1. 什么是 Token?

Token 是大模型处理文本的最小单位。

  • 对于英文:通常一个 Token 约等于 0.75 个单词(Word),或者约 4 个英文字符。
  • 对于中文:由于中文的特殊性,Qwen3 采用的是基于字节对编码(BPE)的词表。通常情况下,1 个汉字 ≈ 1 到 1.5 个 Token

2. 长度单位对比

为了方便你在 IDP 任务中估算,可以参考下表:

单位含义示例在 max_seq_length=2048 下的规模
字符文本的物理字数“你好世界” = 4 字符约可容纳 1500 - 1800 个汉字
字节计算机存储大小“你好” = 6 字节 (UTF-8)不作为长度限制单位
Token模型的逻辑单位“你好世界” ≈ 4-5 Tokens固定为 2048 个 Token

3. 为什么是 Token 而不是字符?

  • 压缩效率:模型通过 Token 将常用词组(如“中华人民共和国”)压缩成一个或少数几个数字,从而提高处理效率。
  • 计算限制:模型的注意力机制(Attention)矩阵是基于 Token 数量进行计算的,显存占用直接与 Token 数量的平方相关。

4. 你的 IDP 任务如何换算?

如果你要处理一份 3000 字的法律合同:

  1. 汉字与标点:约 3000 字符。
  2. 转换为 Token:大约需要 4000 - 4500 个 Token。
  3. 结论:如果你设置 max_seq_length = 2048,这份合同在训练或推理时会被截断掉一半以上的内容。

建议做法

如果你不确定自己的数据长度,可以在 Colab 中运行以下代码来查看你最长的一条数据有多少个 Token:

token_counts = [len(tokenizer.encode(text)) for text in dataset["text"]]
print(f"最长数据 Token 数: {max(token_counts)}")

根据这个输出结果来设置你的 max_seq_length,既能保证数据不被截断,又能最大化节省显存。