其实也不爬虫只是ctrl+s了一个html然后调库解析而已想写async还失败了

莫提威甚

学习Spring Boot中,打开了其 Api Doc.
看到大片的英文懒得思考(讲道理智力正常的人类是不是也不会直接看 Api Doc.
点击 chrome 自带的页面翻译依旧宕机,打开F12看到页面规整得要命(至少我想爬的东西还挺规则),一看又是纯html,于是启动py准备自己写脚本机翻。
> 初见端倪

么梭多洛基

解析页面

前世记忆之xpath

首先反正只有一个页面于是连 requests 都懒, Ctrl+S 存下来直接开造。
启动F12后观察到这些表格元素的规律:

<div class="summary-table two-column-summary">
<div class="table-header col-first">Package</div>
<div class="table-header col-last">Description</div>
<div class="col-first even-row-color all-packages-table all-packages-table-tab1">
<a href="org/springframework/boot/package-summary.html">org.springframework.boot</a>
</div>
<div class="col-last even-row-color all-packages-table all-packages-table-tab1">
<div class="block">Core Spring Boot classes.</div>
</div>
...

</div>

哦哦哦class写得都很有规律不赖,但是其实我完全忘了py怎么解析html,紧急询问了鲸爷deepseek
> 警告一次
好的于是uv add beautifulsoup4 lxml

继续观察到鲸爷说:

鲸爷:
✅ 1. BeautifulSoup(最主流)

适合:大多数爬虫、页面解析、数据提取

✅ 优点:易学、容错强
❌ 缺点:速度一般(但够用)

✅ 2. lxml + XPath(速度快、语法强)

适合:复杂结构、高性能解析

✅ 优点:快、表达能力强
❌ 缺点:XPath 学习成本稍高

突然前世记忆占领大脑,想起来其实xpath更好用点,毕竟最轮椅的是在F12里右键html可以直接copy xpath还挺方便,虽然一般直接copy的是根目录开始的xpath也就纯对单,不过至少不需要大脑。
但是这次要扒同一个页面的这么多东西肯定不用copy xpath,那么找规律启动!
> 警告两次

笑点+2:class 标签 + div 套娃

不难观察到其实F12里有一大片col-first ...table-tab1col-last ...table-tab1,于是有:

# 前面一些 with open 啥的,总之 html := 我ctrl+s的那坨html
tree = etree.HTML(html)

col0_class = "col-first even-row-color all-packages-table all-packages-table-tab1" # > 笑点解析
col1_class = "col-last even-row-color all-packages-table all-packages-table-tab1" # > 笑点解析

text_col0 = tree.xpath(f'//div[@class="{col0_class}"]/a/text()')
text_col1 = tree.xpath(f'//div[@class="{col1_class}"]/div/text()')

df = pd.DataFrame(
{
"Package": text_col0,
"Description": text_col1,
}
)
df.to_csv("./test01.csv")

不出意外也是df哈气报ValueError: All arrays must be of the same length了,嗐,html嘛,数据脏点很正常。
看看len:

2026-05-28 09:43:04 [INFO] => Done text_col0, len: 251
2026-05-28 09:43:04 [INFO] => Done text_col1, len: 274

> 这时候甚至还没意识到数字太小
> 哦对了关于logging配置这一块:

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)

对不上那就先可视化printf看看从哪里对不上:

max_len = max(len(text_col0), len(text_col1))
max_str_len_col_0 = max(map(len, text_col0))
max_str_len_col_1 = max(map(len, text_col1))
logger.info(f"=> max_str_len_col_0: {max_str_len_col_0}") # 83
logger.info(f"=> max_str_len_col_1: {max_str_len_col_1}") # 122

with open("./test01.txt", "w", encoding="utf8") as fp:
for i in range(max_len):
col0_text = text_col0[i] if i < len(text_col0) else "<none>"
col1_text = text_col1[i] if i < len(text_col1) else "<none>"

fp.write(f"{col0_text:<{max_str_len_col_0 + 2}}:={col1_text}\n")

# 总而言之就是 outer join, max_str_len 用来控制 := 的位置对齐

观察test01.txt中,发现一些 Description 的内容有点破碎;
回头查看页面发现 Description 里可能嵌套一些行内代码格式导致div下还有div,于是 xpath 的text()取的稀里糊涂。

但是查看页面时发现不对,为什么test01.txt感觉缺了好多行,再查看 F12 :

<div class="col-first even-row-color all-packages-table all-packages-table-tab1">...</div>
<div class="col-last even-row-color all-packages-table all-packages-table-tab1">...</div>
<div class="col-first odd-row-color all-packages-table all-packages-table-tab1">...</div>
<div class="col-last odd-row-color all-packages-table all-packages-table-tab1">...</div>

。。。

<div class="... even-row-color ...">...</div>
<div class="... odd-row-color ...">...</div>

> 猪!!!!!
啊哈时刻

🐋: contains class

好吧那还是得看看 class 里有什么的,显然好像选择 col-firstcol-last 比较好,于是:

text_col0 = tree.xpath(f'//div[@class="col-first"]/a/text()')
text_col1 = tree.xpath(f'//div[@class="col-last"]/div/text()')

。。。

2026-05-28 09:53:59 [INFO] => Done text_col0, len: 0
2026-05-28 09:53:59 [INFO] => Done text_col1, len: 0

鲸爷救我——

text_col0 = tree.xpath(f'//div[contains(@class, "col-first")]/a/text()')
text_col1 = tree.xpath(f'//div[contains(@class, "col-last")]/div/text()')

也是终于把偶数行也爬出来了,但是那个问题还没解决:

死亡回放:
回头查看页面发现Description里可能嵌套一些行内代码格式导致div下还有div,于是xpath的text()取的稀里糊涂。

🐋: string(.)

依旧鲸爷神力:

text_col1 = tree.xpath(f'//div[contains(@class, "{col1_class}")]')
text_col1 = [x.xpath("string(.)").replace("\n", " ") for x in text_col1[1:]]
# replace 手动丢掉不知道哪里来的换行, [1:] 丢掉 table-header col-last

鲸爷:
📌 string(.) 的含义:

  • .:当前节点
  • string():递归拼接该节点下 所有文本节点

半场香槟

ok啊,现在输出也是非常地干净整洁心旷神怡:

org.springframework.boot                         := Core Spring Boot classes. 
org.springframework.boot.activemq.autoconfigure := Auto-configuration for ActiveMQ.
...

速度也挺快的其实完全1s内。

2026-05-28 10:14:21 [INFO] => Done read
2026-05-28 10:14:21 [INFO] => Done etree
2026-05-28 10:14:21 [INFO] => Done text_col0, len: 502
2026-05-28 10:14:21 [INFO] => Done text_col1, len: 502
2026-05-28 10:14:21 [INFO] => max_str_len_col_0: 83
2026-05-28 10:14:21 [INFO] => max_str_len_col_1: 122
2026-05-28 10:14:21 [INFO] => Done save

那么解析页面就结束了

翻译

🐋: translators

翻译感觉暂时还不是我本地小破机能跑的东西,直接怼鲸爷api里做翻译也挺诡异的,于是问鲸爷。 > ?
好的于是问到了:

鲸爷:

1. translators ⭐推荐

封装 Google、Bing、百度、有道、DeepL、Yandex 等 30+ 引擎,统一接口。

pip install translators --upgrade
import translators as ts
result = ts.translate_text("Hello World", to_language='zh', translator='bing')
print(result)
  • ✅ 零配置、支持切引擎对比
  • ⚠️ 国内访问 Google 需代理,建议用 translator='bing''baidu'

不赖,uv add translators,顺手装个tqdm看看速度。

2%|██                                                                                                   | 10/502 [00:15<12:55,  1.58s/it]

!?这么慢?!

(SPOILED WARNING) 烂尾的asynico

于是鬼脑发动,都 web api 了不难想到 asynico ,那么我是否要抓住此生仅有 (> ¿)的机会学习一下神秘的异步。
其实是发现了:

# in .venv\Lib\site-packages\translators\__init__.py
def translate_text(*args, **kwargs):
if kwargs.get('if_use_async', False):
return translate_text_with_async(*args, **kwargs)
return translate_text_with_sync(*args, **kwargs)

它都做 async 了我岂能不用?

首先咨询鲸爷:

translators 本身是同步库,不支持真正的 async/await,但可以通过 多线程 / 多进程 / 协程包装 来实现“异步并发翻译多个文本”。下面按推荐程度给你几种实战可用的写法。
。。。不信

但是鲸爷给的第一个方案还没怎么听说过,虽然今天完全没有用到但是总之先拉一坨在这里:

✅ 推荐方案一:ThreadPoolExecutor(最简单 & 实用)

适合 I/O 密集(网络请求),对 ts.translate_text 最友好。

import translators as ts
from concurrent.futures import ThreadPoolExecutor, as_completed

texts = [
"Hello world",
"Machine learning is amazing",
"How are you today?"
]

def translate_one(text):
return ts.translate_text(
text,
to_language='zh',
translator='bing' # 国内推荐 bing / baidu
)

with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(translate_one, t) for t in texts]
results = [f.result() for f in as_completed(futures)]

for r in results:
print(r)

✅ 优点

  • 不阻塞事件循环
  • 代码简单
  • translators 最稳定

⚠️ 不建议 max_workers > 10,否则容易被目标站点限流。

俺寻思异步

总之先俺寻思写了一点 async

# src_line := [(<Package>:str, <Description>:str), ...]
async def get_one_tgt_line(line):
ts_res = (
line[0], line[1],
await ts.translate_text(
line[1], to_language='zh', translator='bing',
if_use_async=True
)
)
return ts_res

async def get_all_tgt_line():
tgt_line_task = [get_one_tgt_line(line) for line in src_line]
tgt_line = await asyncio.gather(*tgt_line_task)
return tgt_line

tgt_line = asyncio.run(get_all_tgt_line())

with open("./test02.txt", "w", encoding="utf8") as fp:
for line in tgt_line:
fp.write(f"{line[0]:<85}:={line[1]} / {line[2]}") # > 笑点解析

再求救鲸爷加了限制同时请求数量:

sem = asyncio.Semaphore(5)  # 最多 5 个同时运行
async def get_one_tgt_line(line):
async with sem:
ts_res = ...
return ts_res

再再求救鲸爷加了进度播报,以及自己补了个统计单个请求耗时的计时:

class Counter:
def __init__(self):
self._value = 0
self._lock = asyncio.Lock()

async def inc(self):
async with self._lock:
self._value += 1
return self._value

@property
def value(self):
return self._value

counter = Counter()

async def get_one_tgt_line(line):
tic = time()
async with sem:
ts_res = ...
tac = time()
done = await counter.inc()
print(f"=> {done:>3}/{len(src_line)}, spent {tac - tic:.2f} s")
return ts_res

完 全 战 败

启动!

Unclosed client session
client_session: <aiohttp.client.ClientSession object at 0x000002032AB4E900>
Unclosed client session
client_session: <aiohttp.client.ClientSession object at 0x000002032A861BD0>
Unclosed client session
client_session: <aiohttp.client.ClientSession object at 0x000002032AB3F390>
Unclosed client session
client_session: <aiohttp.client.ClientSession object at 0x000002032A860F50>
bing aiohttp session closed.
=> 1/502, spent 2.41 s
Traceback (most recent call last):
File "...\test02.py", line 57, in <module>
tgt_line = asyncio.run(get_all_tgt_line())
...
File "...\test02.py", line 54, in get_all_tgt_line
tgt_line = await asyncio.gather(*tgt_line_task)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\test02.py", line 42, in get_one_tgt_line
await ts.translate_text(
...<2 lines>...
)
File "...\.venv\Lib\site-packages\translators\server_async.py", line 6328, in translate_text
result = await self.translators_dict[translator](query_text=query_text, from_language=from_language, to_language=to_language, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\.venv\Lib\site-packages\translators\server_async.py", line 122, in _wrapper
return await func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\.venv\Lib\site-packages\translators\server_async.py", line 294, in _wrapper
return await func(*args, **{**kwargs, **{'query_text': query_text}})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\.venv\Lib\site-packages\translators\server_async.py", line 1971, in bing_api
response = await self.session.post(api_url, headers=self.host_headers, data=payload, timeout=timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
aiohttp.client_exceptions.ServerDisconnectedError: Server disconnected

。。。好的那我们还是用同步方法吧不就十分钟吗

100%|████████████████████████████████████████████████████████████████████████████████████████████████████| 502/502 [11:10<00:00,  1.34s/it]

战败结算

好的也是跑完了哈看看结果
我是结果
怎么只有一行?

fp.write(f"{line[0]:<85}:={line[1]} / {line[2]}") # > 笑点解析

。。。

于是急头白脸又写了个脚本把每一行拆开来。
拆开来了
好现在好看多了。

肯克录甚

> 完全猪!!!!!

也不能这么说吧应该还是复习了一下xpath啥的也难得捣鼓一下asynico虽然完全没成功。

但是谁还记得我一开始是来学Spring Boot