本地生词本构建流程

项目概述

使用本地 Ollama 模型 + Python 脚本 + 静态 HTML 页面,搭建一个离线可用的英语生词本。整个流程不需要联网(除首次拉取模型和词库外),所有数据保存在本地。


环境准备

1. 安装 Python 环境

1
2
3
4
5
6
7
# 检查是否已安装 Python
python3 --version

# 如果没有,安装 Python 3.8+

sudo apt install python3 python3-pip

也可以访问之前的部署环境文档
查看 Python 3.11 环境搭建

2. 安装依赖包

1
pip3 install nltk requests beautifulsoup4

3. 安装 Ollama

1
2
3
4
5
# Ubuntu/Debian
curl -fsSL https://ollama.com/install.sh | sh

# 启动 Ollama 服务
ollama serve

4. 拉取本地模型

1
2
# 拉取 llama3.1:8b(英文能力强,约 4.9GB)
ollama pull llama3.1:8b

5. 下载 NLTK 词库

在终端中执行以下命令,下载 WordNet 和同义词库数据:

1
2
python3 -c "import nltk; nltk.download('wordnet'); nltk.download('omw-1.4')"
---

项目文件结构

1
2
3
4
5
6
source/
├── _posts/
│ ├── 生单词.md # 生成的 Markdown 词条文件
│ ├── add_word.py # 核心脚本:查询 WordNet + 调用 Ollama 生成词条
│ └──add-word.sh # Shell 封装:检查环境 + 调用 Python 脚本


核心脚本说明

add-word.py

负责以下工作:

  1. 查询 WordNet:获取单词的释义、同义词、反义词和例句,作为 AI 生成的参考依据。
  2. 调用 Ollama:将 WordNet 数据 + 提示词发送给本地模型,生成结构化的词典条目(中文意思、用法、其他形式、例句)。
  3. 追加写入 Markdown:将生成的词条内容追加到 生单词.md 中,每个单词作为一个独立的 word-item 块,方便后续检索和展示。

add-word.sh

Shell 封装脚本,负责:

  1. 校验是否传入了单词参数。
  2. 获取脚本所在目录,确保能找到 add-word.py。
  3. 遍历所有传入的单词,逐个调用 add-word.py 处理。

展示页面说明

生单词.md

Hexo 源文件,由 Python 脚本追加写入。每个单词是一个 <div class="word-item"> 块,包含:

  1. 单词标题:### 单词名,作为词条入口。
  2. 中文意思:逐条列出义项,带词性标注。
  3. 用法:固定搭配 + 常见用法。
  4. 其他形式:派生词、词形变化、反义词。
  5. 例句:每条配 <button class="speak-btn">,前端通过 speechSynthesis API 朗读。

Hexo 部署后会自动将该文件渲染为 HTML 页面,配合前端 JS/CSS 实现 A-Z 索引、搜索过滤、展开收起等功能。

操作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1. 检查本机环境
python3 --version
pip3 --version
ollama --version

# 2. 安装 Python 依赖包
pip3 install nltk requests beautifulsoup4

# 3. 下载 NLTK 词库
python3 -c "import nltk; nltk.download('wordnet'); nltk.download('omw-1.4')"

# 4. 拉取 Ollama 模型
ollama pull llama3.1:8b

# 5. 给脚本添加执行权限
chmod +x add-word.py
chmod +x add-word.sh

# 6. 添加一个单词
./add-word.sh presentation

# 7. 批量添加多个单词
./add-word.sh apple banana cherry


完整代码

add_word.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
#!/usr/bin/env python3
"""添加单词到本地生词本。"""
#!/usr/bin/env python3
"""
add-word.py
用法:python3 add-word.py <单词>
"""

import sys
import os
import re

import requests

try:
from nltk.corpus import wordnet as wn
except ImportError:
wn = None

OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1:8b")
OLLAMA_BASE = os.environ.get("OLLAMA_BASE", "http://127.0.0.1:11434")
TARGET_FILE = os.path.join(os.path.dirname(__file__), "生单词.md")


def lookup_wordnet(word: str) -> dict:
"""直接调用 NLTK WordNet 查询,不依赖本地 JSON。"""
if wn is None:
return {}

try:
synsets = wn.synsets(word)
except Exception as e:
print(f"WordNet 查询失败:{e}")
return {}

meanings = []
examples = []
synonyms = set()
antonyms = set()

for s in synsets:
definition = (s.definition() or "").strip()
if definition:
meanings.append(definition)

for ex in s.examples():
ex = ex.strip()
if ex and ex not in examples:
examples.append(ex)

for lemma in s.lemma_names():
synonyms.add(lemma)

# 反义词:通过 lemma 获取
try:
for lemma in s.lemmas():
for ant in lemma.antonyms():
antonyms.add(ant.name())
except Exception:
pass

synonyms.discard(word)
synonyms.discard(word.lower())
synonyms.discard(word.capitalize())

return {
"meanings": meanings,
"examples": examples,
"synonyms": sorted(synonyms)[:10],
"antonyms": sorted(antonyms)[:10],
}

def ask_ollama(word: str, wordnet_data: dict) -> str:
"""调用本地 Ollama 生成词典条目。"""
meanings = wordnet_data.get("meanings", []) if wordnet_data else []
expected_count = len(meanings) if meanings else 5

prompt = (
f"你是一位英语词典助手。只输出词典内容,不要写代码、不要解释推理过程、不要输出多余文字。\n\n"
f"请针对单词 '{word}' 输出以下内容:\n"
f"1. 中文意思:这是最重要的一节,必须先完整输出。"
f"每条以词性缩写开头,如 adj. / n. / v. / adv.;"
f"必须严格输出 {expected_count} 条,一条对应一个义项,禁止把多个义项用分号合并到同一行;\n"
f"若某义项属于专业领域,请在中文后注明,例如「n.(产科)胎位/先露」;\n"
f"2. 用法:分两类——\n"
f" - 固定搭配:相对固定的短语,必须包含单词 '{word}' 本身;\n"
f" - 常见用法:一般性搭配或用法说明;\n"
f"3. 其他形式:只列该词的派生词、词形变化或同根词,例如 presenter / presentable / presentational;"
f"不要写近义词、不要写 unrelated words;格式为「单词(词性. 中文含义)」;\n"
f"4. 例句:为上面每一个中文义项各写 1 个英文例句,共 {expected_count} 条;"
f"每条例句中必须出现单词 '{word}' 本身,不得用它的动词/形容词/其他词形替代;"
f"每条后面用中文括号附翻译,例句要体现对应义项,不要重复。\n\n"
f"参考信息(来自 WordNet,可作辅助,不必照搬):\n"
)

if meanings:
prompt += "释义(请逐条对应,不要遗漏):\n"
for i, m in enumerate(meanings, 1):
prompt += f"{i}. {m}\n"
if wordnet_data.get("synonyms"):
prompt += f"同义词:{', '.join(wordnet_data['synonyms'][:8])}\n"
prompt += (
f"\n硬性要求:「中文意思」必须恰好输出 {expected_count} 条,"
f"与上面释义一一对应,编号 1 到 {expected_count} 都要有,不得合并、不得省略。\n"
)
else:
prompt += (
f"(WordNet 未找到该单词,请根据你自己的知识作答,"
f"「中文意思」至少输出 {expected_count} 条)\n"
)

example_lines = "\n".join(
f"- n. 中文义项{i}" for i in range(1, expected_count + 1)
)

prompt += (
"\n请严格按以下格式输出,不要添加其他标题或说明:\n"
"**中文意思**\n"
f"{example_lines}\n"
"**用法**\n"
"- 固定搭配:give a {word} 做{word} / 发表{word}\n"
"- 常见用法:make a {word} to... 向...做{word}\n"
"**其他形式**\n"
"- 单词(词性. 中文含义)\n"
"**例句**\n"
"- 英文例句(中文翻译)\n"
).format(word=word)

url = f"{OLLAMA_BASE}/api/generate"
payload = {
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.3,
"num_predict": 2048,
},
}

resp = requests.post(url, json=payload, timeout=180)
resp.raise_for_status()
data = resp.json()

print("done_reason:", data.get("done_reason"))
print("eval_count:", data.get("eval_count"))

return data["response"]

def parse_ollama_output(text: str) -> dict:
"""把模型输出解析成结构化字典。"""
result = {
"meaning": [],
"collocations": [],
"usage": [],
"forms": [],
"examples": [],
}

parts = re.split(r"\*\*(.+?)\*\*", text)
current_title = None
for i, part in enumerate(parts):
if i == 0:
continue
if i % 2 == 1:
current_title = part.strip()
else:
body = part.strip()
if not current_title or not body:
continue

if "中文意思" in current_title or "意思" in current_title:
result["meaning"] = [
line.strip().lstrip("-").strip()
for line in body.splitlines()
if line.strip()
]

elif current_title == "用法":
for line in body.splitlines():
line = line.strip()
if not line:
continue
clean = line.lstrip("-").strip()
if not clean:
continue
if clean.startswith("固定搭配:") or clean.startswith("固定搭配:"):
result["collocations"].append(clean)
else:
result["usage"].append(clean)

elif "其他形式" in current_title or "形式" in current_title:
result["forms"] = [
line.strip().lstrip("-").strip()
for line in body.splitlines()
if line.strip()
]

elif "例句" in current_title:
result["examples"] = [
line.strip().lstrip("-").strip()
for line in body.splitlines()
if line.strip()
]

return result


def build_markdown_block(word: str, wn_data: dict, ollama_text: str) -> str:
"""组装最终的 HTML + Markdown 块。"""
ollama_parsed = parse_ollama_output(ollama_text)

# 中文意思
if ollama_parsed and ollama_parsed["meaning"]:
meanings_block = "\n".join(f"- {m}" for m in ollama_parsed["meaning"])
elif wn_data and wn_data.get("meanings"):
meanings_block = "\n".join(f"- {m}" for m in wn_data["meanings"])
else:
meanings_block = "- (未获取到释义,请手动补充)"

# 用法:固定搭配在前,常见用法在后
usage_lines = []
if ollama_parsed and ollama_parsed["collocations"]:
usage_lines.extend(ollama_parsed["collocations"])
if ollama_parsed and ollama_parsed["usage"]:
usage_lines.extend(ollama_parsed["usage"])
if not usage_lines:
usage_lines.append("(待补充)")
usage_block = "\n".join(f"- {u}" for u in usage_lines)

# 其他形式
forms_lines = []
if ollama_parsed and ollama_parsed["forms"]:
forms_lines.extend(ollama_parsed["forms"])
if wn_data and wn_data.get("antonyms"):
forms_lines.append(f"反义词:{', '.join(wn_data['antonyms'][:8])}")
if not forms_lines:
forms_lines.append("(待补充)")
forms_block = "\n".join(f"- {f}" for f in forms_lines)

# 例句
examples = []
if ollama_parsed and ollama_parsed["examples"]:
examples.extend(ollama_parsed["examples"][:3])
elif wn_data and wn_data.get("examples"):
examples.extend(wn_data["examples"][:3])
if not examples:
examples.append("(待补充)")

speak_buttons = ""
for ex in examples:
clean = re.sub(r"[((].*?[))]", "", ex).strip()
if clean:
speak_buttons += (
f'- <button class="speak-btn" data-text="{clean}">{clean}</button>'
)
if "(" in ex:
speak_buttons += ex[ex.rfind("("):]
elif "(" in ex:
speak_buttons += ex[ex.rfind("("):]
speak_buttons += "\n"
else:
speak_buttons += f"- {ex}\n"

block = f"""<div class="word-item" data-word="{word}">

### {word}

**意思**
{meanings_block}

**用法**
{usage_block}

**其他形式**
{forms_block}

**例句**
{speak_buttons.rstrip()}
</div>
"""
return block


def main():
if len(sys.argv) < 2:
print("用法:python3 add-word.py <单词>")
sys.exit(1)

word = sys.argv[1].strip().lower()

print(f"正在查询 WordNet:{word}")
wn_data = lookup_wordnet(word)
print(f"WordNet 义项数:{len(wn_data.get('meanings', []))}")

print(f"正在调用 Ollama(模型:{OLLAMA_MODEL})...")
ollama_text = ask_ollama(word, wn_data)

block = build_markdown_block(word, wn_data, ollama_text)

with open(TARGET_FILE, "a", encoding="utf-8") as f:
f.write(block + "\n")

print(f"已追加到:{TARGET_FILE}")


if __name__ == "__main__":
main()

add-word.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
set -e

if [ $# -eq 0 ]; then
echo "用法:./add-word.sh <单词1> <单词2> ..."
exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

for WORD in "$@"; do
echo "=============================="
echo "正在处理:$WORD"
echo "=============================="
python3 "$SCRIPT_DIR/add-word.py" "$WORD"
done

生单词.md

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
---
title: 生单词
date: 2026-10-05 00:00:00
categories:
- 英语
tags:
- 单词
- 发音
---

## 单词笔记

<div class="word-toolbar">
<input id="word-search" type="text" placeholder="搜索单词或例句..." class="word-search" />
<div id="alpha-index" class="alpha-index"></div>
</div>
<div id="word-list">
<div class="word-item" data-word="attractive">

### attractive

**意思**
- adj. 有吸引力的、迷人的
- adj. 有诱惑力的、令人感兴趣的

**用法**
- an attractive price 有吸引力的价格
- attractive to sb. 对某人有吸引力
- an attractive offer 一份诱人的报价

**其他形式**
- attract(v. 吸引)
- attraction(n. 吸引力;景点)
- attractively(adv. 有吸引力地)
- attractiveness(n. 吸引力)

**例句**
- <button class="speak-btn" data-text="The job is attractive because it offers flexible working hours.">The job is attractive because it offers flexible working hours.</button>(这份工作很有吸引力,因为它提供弹性工作时间。)
- <button class="speak-btn" data-text="The new design makes the product more attractive to young customers.">The new design makes the product more attractive to young customers.</button>(新设计让这款产品对年轻消费者更有吸引力。)
- <button class="speak-btn" data-text="There are many attractive tourist attractions in this city.">There are many attractive tourist attractions in this city.</button>(这座城市有很多有吸引力的旅游景点。)
</div>

</div>

<style>
.word-toolbar {
margin: 12px 0 20px;
}

.word-search {
width: 100%;
max-width: 320px;
padding: 6px 12px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 14px;
margin-bottom: 12px;
box-sizing: border-box;
}

.alpha-index {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.alpha-item {
padding: 4px 10px;
border: 1px solid #d0d0d0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
user-select: none;
}

.alpha-item:hover {
background: #4a90d9;
color: #fff;
}

.alpha-item.active {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}

.alpha-item em {
font-style: normal;
font-size: 12px;
opacity: 0.7;
}

#word-list {
display: flex;
flex-direction: column;
gap: 24px;
}

.word-item {
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fafafa;
}

.word-item.hidden {
display: none;
}

.word-empty {
padding: 16px;
color: #999;
font-size: 14px;
display: none;
}

.speak-btn {
cursor: pointer;
border: none;
background: #4a90d9;
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 14px;
}

.speak-btn:hover {
background: #357abd;
}
</style>

<script>
document.addEventListener('DOMContentLoaded', function () {
var container = document.getElementById('word-list');
if (!container) return;

var items = Array.prototype.slice.call(
container.querySelectorAll('.word-item')
);

// 1. A-Z 排序
items.sort(function (a, b) {
var wa = (a.getAttribute('data-word') || '').toLowerCase();
var wb = (b.getAttribute('data-word') || '').toLowerCase();
return wa.localeCompare(wb, 'en');
});

items.forEach(function (item) {
container.appendChild(item);
});

// 2. 补 id
items.forEach(function (item) {
var word = (item.getAttribute('data-word') || '').toLowerCase();
if (word) item.id = 'word-' + word;
});

// 3. 空结果提示
var emptyTip = document.createElement('div');
emptyTip.className = 'word-empty';
emptyTip.textContent = '没有匹配的单词。';
container.appendChild(emptyTip);

// 4. 字母索引
var indexBox = document.getElementById('alpha-index');
var currentLetter = 'ALL';

if (indexBox) {
var letters = {};

items.forEach(function (item) {
var first = (item.getAttribute('data-word') || '').charAt(0).toUpperCase();
if (!/[A-Z]/.test(first)) return;
if (!letters[first]) letters[first] = [];
letters[first].push(item);
});

var keys = Object.keys(letters).sort();
var html = '<span class="alpha-item active" data-target="ALL">全部</span>';

keys.forEach(function (k) {
html += '<span class="alpha-item" data-target="' + k + '">' +
k + ' <em>(' + letters[k].length + ')</em></span>';
});

indexBox.innerHTML = html;

indexBox.addEventListener('click', function (e) {
var el = e.target.closest('.alpha-item');
if (!el) return;

indexBox.querySelectorAll('.alpha-item').forEach(function (s) {
s.classList.remove('active');
});
el.classList.add('active');

currentLetter = el.getAttribute('data-target');
applyFilter();
});
}

// 5. 搜索框
var searchBox = document.getElementById('word-search');
if (searchBox) {
searchBox.addEventListener('input', applyFilter);
}

// 6. 统一筛选逻辑:字母 + 关键词同时生效
function applyFilter() {
var keyword = '';
if (searchBox) {
keyword = searchBox.value.trim().toLowerCase();
}

var visibleCount = 0;

items.forEach(function (item) {
var word = (item.getAttribute('data-word') || '').toLowerCase();
var first = word.charAt(0).toUpperCase();

var letterOk = (currentLetter === 'ALL') || (first === currentLetter);
var keywordOk = !keyword || item.textContent.toLowerCase().indexOf(keyword) !== -1;

if (letterOk && keywordOk) {
item.classList.remove('hidden');
visibleCount++;
} else {
item.classList.add('hidden');
}
});

emptyTip.style.display = visibleCount === 0 ? 'block' : 'none';
}

// 7. 发音
container.addEventListener('click', function (e) {
var btn = e.target;
if (!btn || !btn.classList.contains('speak-btn')) return;

var text = btn.getAttribute('data-text');
if (!text || !window.speechSynthesis) return;

var u = new SpeechSynthesisUtterance(text);
u.lang = 'en-US';
u.rate = 0.9;

window.speechSynthesis.cancel();
window.speechSynthesis.speak(u);
});
});
</script>

注意事项

  1. Ollama 服务必须先启动,否则脚本无法调用模型。
  2. 首次运行会比较慢,因为模型需要加载到内存中;后续调用会快很多。