1. 项目概述一个为土耳其语设计的智能票价计算器最近在做一个跟公共交通相关的项目需要处理土耳其语的票价信息偶然间发现了这个名为“Fare-imleci-vurgulayici”的仓库。这个名字直译过来就是“票价计算器-强调器”听起来有点抽象但深入探究后我发现它实际上是一个专门针对土耳其语文本的、用于智能识别和突出显示票价相关信息的工具库或脚本。在土耳其尤其是伊斯坦布尔这样的超大城市公共交通系统非常复杂地铁、公交、渡轮、有轨电车Tramvay和缆车Füniküler构成了庞大的网络。票价体系也随之复杂化有单次票、多次票、储值卡İstanbulkart、学生票、老年人票等多种类型并且票价会根据乘坐的交通工具类型、换乘次数、乘坐距离在某些线路上以及支付方式现金或电子卡而动态变化。这就导致在新闻、公告、社交媒体甚至官方App的说明文本中关于票价的描述往往是一段夹杂着数字、货币符号₺、票种名称和复杂条件的土耳其语长句。对于普通用户尤其是外国游客或不熟悉当地规则的人来说快速从一段文字中提取出核心的票价数字和适用条件是一件挺头疼的事。这个“Fare-imleci-vurgulayici”项目就是为了解决这个问题而生。它不是一个完整的票价计算App而更像是一个“文本挖掘引擎”或“信息提取增强模块”。它的核心任务是输入一段土耳其语文本自动找出其中所有与“票价”相关的数字和关键描述词如“ücreti”、“bilet”、“İstanbulkart ile”等并以高亮、加粗或添加特殊标记的方式将它们突出显示从而帮助用户或下游程序快速定位和理解票价信息。2. 核心需求与设计思路拆解2.1 为什么需要专门的土耳其语票价提取工具你可能会问用正则表达式匹配数字和“TL”土耳其里拉不就行了吗在实际场景中这远远不够。土耳其语票价文本的复杂性催生了对这个专用工具的需求。首先语言特异性。土耳其语是一种黏着语通过添加丰富的后缀来表达语法关系。例如“票价”是“ücret”但“它的票价”是“ücreti”“单程票价”可能是“tek yön ücreti”。一个简单的关键词匹配很容易漏掉这些变体。此外数字的书写方式也可能包含小数点逗号和千位分隔符点例如“5,50 TL”或“15.00 TL”这在正则表达式中需要精细处理。其次上下文关联性。一个孤立的数字“3.50”可能是票价也可能是时间、站台号或其他信息。判断一个数字是否为票价极度依赖其周围的上下文词汇。例如“Öğrenci bileti 3,50 TLdir.”学生票是3.50 TL和“3,50 dakika sonra kalkar.”3.5分钟后发车。前者中的“3,50”是票价后者则是时间。工具必须能理解“bileti”票、“TL”货币这类强关联词。最后条件信息的提取。票价往往附带条件如“İstanbulkart ile aktarmada ücretsiz”使用伊stanbul卡换乘免费、“65 yaş üstü ücretsiz”65岁以上免费。一个优秀的提取器不仅要标出价格最好还能关联并标出这些条件形成结构化的信息片段。这正是“vurgulayici”强调器想达到的更高目标——不仅仅是高亮更是信息的结构化提取和呈现。2.2 项目的整体架构设想基于以上需求一个合理的“Fare-imleci-vurgulayici”应该包含以下核心模块文本预处理模块负责接收原始土耳其语文本进行基础清洗如去除多余空格、统一字符编码、句子分割和分词。考虑到土耳其语的形态一个简单的空格分词可能不够需要集成或调用土耳其语的分词库如Zemberek一个著名的土耳其语NLP库。关键词与模式匹配引擎这是核心之一。需要维护一个动态的“票价相关词典”包括货币与单位TL,₺,lira,kuruş库鲁分。票务核心名词ücret费用,bilet票,fiyat价格,geçiş通行,yolculuk旅程。票种类型öğrenci学生,tam全价,indirimli折扣,aile家庭,günlük日票,haftalık周票。支付与条件İstanbulkart ile用İstanbul卡,nakit现金,aktarma换乘,ücretsiz免费,ilk首次,sonraki后续。 引擎需要支持这些关键词的变体形式如带后缀的ücreti,bileti的模糊匹配。同时编写一系列正则表达式模式来捕获常见的票价表述模式如[\d.,]\s*(TL|₺|lira)。上下文分析与消歧模块利用简单的规则或轻量级机器学习模型如基于词性的规则或预训练词向量的相似度计算判断匹配到的数字是否真的属于票价上下文。例如如果数字附近出现dakika分钟、saat小时、durak站台等词则可能不是票价应降低其权重或排除。信息关联与结构化模块将匹配到的价格数字与附近的关键词进行关联。例如识别出“Öğrenci bileti 3,50 TL”这个片段并尝试将其结构化为{“ticket_type”: “öğrenci”, “price”: 3.50, “currency”: “TL”}。对于更复杂的条件句如“İstanbulkart ile ilk geçiş 7.67 TL, sonraki aktarmalar ücretsiz.”应能识别出两个信息块一个带价格的首次刷卡一个免费的换乘条件。渲染与输出模块根据结构化信息生成带有高亮标记的文本。输出格式可以是HTML用mark或span style标签、ANSI终端颜色代码或者纯文本附加位置索引供其他应用程序调用。同时也可以选择输出结构化的JSON数据包含提取出的所有票价实体及其元数据位置、类型、关联条件。3. 关键技术点与实现细节3.1 土耳其语文本处理的特殊性处理土耳其语有几个坑是必须提前知道的。首先就是大小写转换。土耳其语中有一个字母“i”它的大写是“İ”上面带点而小写“i”的大写是“I”上面无点。反之大写“I”的小写是“ı”下面无点。如果你用Python默认的str.lower()或str.upper()会得到错误的结果因为它们是针对ASCII设计的。必须使用区域设置或专门的库。# 错误的做法 text İSTANBUL İstanbulkart İLE print(text.lower()) # 输出i̇stanbul i̇stanbulkart i̇le (可能显示乱码且i上有点不符合土耳其语规则) # 正确的做法使用 locale 或 unicodedata 进行规范化处理 import locale locale.setlocale(locale.LC_ALL, tr_TR.UTF-8) # 设置土耳其语区域 # 或者使用 str.casefold() 结合自定义映射或使用土耳其语NLP库如 Zemberek 或 tr 库其次分词。虽然空格是主要分隔符但土耳其语中复合词很多且后缀变化复杂。例如“İstanbulkartımla”用我的İstanbul卡是一个词但包含了所有格后缀-ım和工具格后缀-la。对于高精度场景使用Zemberek这样的库进行词干化和形态分析是更可靠的选择它能将“ücretlerinden”分解为词根“ücret”和后缀“-ler复数-in从属-den从格”这对于精确匹配关键词至关重要。3.2 模式匹配引擎的构建策略构建匹配引擎我倾向于采用“正则表达式为主词典为辅规则后处理”的混合策略。第一步构建正则表达式模式库。不要试图写一个万能的正则而是针对不同场景编写多个模式然后组合使用。import re patterns [ # 匹配基础价格格式数字货币符号 r(\d{1,3}(?:\.\d{3})*(?:,\d{1,2})?)\s*(TL|₺|lira|Lira), # 匹配“X TL Y Kuruş”格式 r(\d)\s*(TL|₺)\s*(\d)\s*kuruş, # 匹配“ücreti X TL”格式 r(ücreti|fiyatı|bedeli)\s*(\d[\d.,]*)\s*(TL|₺)?, # 匹配“bilet X TL”格式 r(bilet|bileti)\s*(\d[\d.,]*)\s*(TL|₺)?, ]第二步构建并维护关键词词典。将关键词按类别分组并考虑其常见变体。可以通过读取文件或配置来管理便于更新。fare_keywords { ticket_type: [öğrenci, tam, indirimli, sosyal, aile, günlük, haftalık, aylık], payment_method: [istanbulkart, istanbulkart ile, nakit, kredi kartı, bankkart], condition: [aktarma, ücretsiz, ilk geçiş, sonraki geçiş, 90 dakika içinde, metro, otobüs, vapur], }第三步实施匹配与关联。遍历文本应用所有正则模式找到所有候选数字和关键词。然后在一个滑动窗口例如匹配数字前后10个词内搜索关联关键词。如果一个价格数字附近找到了öğrenci和bileti就可以给它打上student_ticket的标签。注意正则表达式中的数字匹配要小心处理土耳其语的数字分隔符。\d{1,3}(?:\.\d{3})*用于匹配千位分隔符为点的情况如15.00而(?:,\d{1,2})?用于匹配小数部分逗号分隔。在Python中匹配到字符串后需要将点千位分隔符移除将逗号小数点替换为点再转换为浮点数float(price_str.replace(., ).replace(,, .))。3.3 上下文消歧的实用技巧如何减少误报这里分享几个基于规则的实用技巧距离加权法给每个匹配到的关键词一个基础分价格数字本身也有基础分。然后计算价格数字与每个关键词在文本中的距离词数或字符数距离越近该关键词对价格数字的贡献分数越高。最后价格数字的总得分是所有关联关键词贡献分的加权和。设定一个阈值高于阈值的才被认为是“票价数字”。否定词检测如果价格数字附近出现否定词如değil不是、hariç除外、ücretsiz değil不免费则需要特别小心。例如“İlk 10 dakika ücretsiz değil, 5 TL.”前10分钟不免费5 TL。这里的“5 TL”是价格但“10”和“dakika”在一起就不是价格。规则引擎需要能处理这种否定范围。词性过滤如果集成了分词和词性标注工具可以增加一层过滤只考虑那些出现在名词票价相关名词或动词如ücretlendirilir-被收费附近的数字。出现在动词如kalkar-出发或时间名词附近的数字则更可能是时间或其他信息。4. 从零搭建一个简易版“票价强调器”下面我将演示如何用Python构建一个简化但功能完整的核心模块。这个示例不依赖重型NLP库侧重于展示思路和可运行的代码。4.1 环境准备与依赖我们主要使用标准库re正则表达式和json。为了更好的土耳其语大小写处理可以安装轻量级的tr库pip install tr它提供了简单的土耳其语大小写转换。pip install tr4.2 核心类设计与实现我们创建一个名为FareHighlighter的类。import re from typing import List, Dict, Any, Tuple import json try: from tr import tr_lower HAS_TR True except ImportError: HAS_TR False class FareHighlighter: def __init__(self): self._compile_patterns() self._load_keywords() def _compile_patterns(self): 编译所有预定义的正则表达式模式 # 价格模式 (数字 货币) self.price_patterns [ re.compile(r(\d{1,3}(?:\.\d{3})*(?:,\d{1,2})?)\s*(TL|₺|lira|Lira), re.IGNORECASE), re.compile(r(\d)\s*(TL|₺)\s*(\d)\s*kuruş, re.IGNORECASE), ] # 上下文关键词模式 (用于辅助定位) self.context_patterns { ticket: re.compile(r\b(bilet|ücret|fiyat|geçiş|yolculuk)\w*\b, re.IGNORECASE), type: re.compile(r\b(öğrenci|tam|indirimli|sosyal|aile|günlük|haftalık)\b, re.IGNORECASE), payment: re.compile(r\b(istanbulkart|nakit|kart|kredi)\w*\b, re.IGNORECASE), condition: re.compile(r\b(aktarma|ücretsiz|ilk|sonraki|dakika|saat)\w*\b, re.IGNORECASE), } def _load_keywords(self): 加载关键词词典 (这里硬编码实际可从文件读取) self.keywords { ticket: [bilet, ücret, fiyat, geçiş, yolculuk], type: [öğrenci, tam, indirimli, sosyal, aile, günlük, haftalık], payment: [istanbulkart, nakit, kart, kredi kartı], condition: [aktarma, ücretsiz, ilk geçiş, sonraki geçiş, dakika içinde, metro, otobüs], } def _normalize_text(self, text: str) - str: 简单的文本规范化统一为小写以便匹配处理土耳其语i/I问题 if HAS_TR: # 使用 tr 库进行更准确的土耳其语小写转换 normalized tr_lower(text) else: # 回退方案标准小写但警告用户 normalized text.lower() # 手动替换一些常见错误不完整仅示例 normalized normalized.replace(i̇, i) # 处理可能出现的错误编码 return normalized def find_fare_candidates(self, text: str) - List[Dict[str, Any]]: 在文本中查找所有可能的票价候选 normalized_text self._normalize_text(text) candidates [] # 1. 查找所有价格数字 for pattern in self.price_patterns: for match in pattern.finditer(normalized_text): price_str match.group(1) currency match.group(2) if match.group(2) else TL # 默认货币 # 清理价格字符串并转换为浮点数 try: # 移除千位分隔符点将小数逗号替换为点 clean_price_str price_str.replace(., ).replace(,, .) price_value float(clean_price_str) except ValueError: continue # 转换失败跳过 start, end match.span() candidates.append({ type: price, value: price_value, currency: currency.upper(), original_text: text[start:end], # 保留原始大小写 start: start, end: end, score: 10.0, # 基础分 }) # 2. 查找所有上下文关键词 for key, pattern in self.context_patterns.items(): for match in pattern.finditer(normalized_text): start, end match.span() candidates.append({ type: fcontext_{key}, value: match.group(), original_text: text[start:end], start: start, end: end, score: 5.0, # 关键词基础分 }) # 按起始位置排序 candidates.sort(keylambda x: x[start]) return candidates def _calculate_context_score(self, price_candidate: Dict, all_candidates: List[Dict], window_size100) - float: 计算一个价格候选对象的上下文得分 score price_candidate[score] price_start, price_end price_candidate[start], price_candidate[end] price_center (price_start price_end) / 2 for ctx in all_candidates: if ctx[type].startswith(context_): ctx_center (ctx[start] ctx[end]) / 2 distance abs(ctx_center - price_center) # 距离越近贡献分数越高使用线性衰减可改用指数衰减 if distance window_size: proximity_factor 1.0 - (distance / window_size) score ctx[score] * proximity_factor * 0.5 # 权重系数 return score def highlight_fares(self, text: str, threshold15.0) - Tuple[str, List[Dict]]: 主函数高亮文本中的票价信息。 返回高亮后的文本和提取出的结构化票价信息列表。 all_candidates self.find_fare_candidates(text) price_candidates [c for c in all_candidates if c[type] price] structured_fares [] # 为每个价格计算上下文得分 for price in price_candidates: price[final_score] self._calculate_context_score(price, all_candidates) # 筛选出得分高于阈值的价格并尝试结构化 high_scored_prices [p for p in price_candidates if p[final_score] threshold] # 简单去重如果两个价格位置重叠取分高的 high_scored_prices.sort(keylambda x: x[final_score], reverseTrue) selected_prices [] used_positions set() for p in high_scored_prices: overlap False for pos in range(p[start], p[end]): if pos in used_positions: overlap True break if not overlap: selected_prices.append(p) used_positions.update(range(p[start], p[end])) # 生成高亮文本 (使用HTML标记) highlighted_text_parts [] last_end 0 for p in sorted(selected_prices, keylambda x: x[start]): highlighted_text_parts.append(text[last_end:p[start]]) highlighted_text_parts.append(fmark classfare-highlight{text[p[start]:p[end]]}/mark) last_end p[end] # 构建结构化信息 fare_info { price: p[value], currency: p[currency], text_snippet: text[max(0, p[start]-30): min(len(text), p[end]30)], } structured_fares.append(fare_info) highlighted_text_parts.append(text[last_end:]) highlighted_html .join(highlighted_text_parts) return highlighted_html, structured_fares # 使用示例 if __name__ __main__: highlighter FareHighlighter() sample_texts [ İstanbulda öğrenci bileti 3,50 TL, tam bilet ise 7,67 TLdir. İstanbulkart ile aktarmalar ücretsiz., Vapur ücretleri tam 15.00 TL, indirimli 7.50 TL. Nakit ödemede 20 TL alınmaktadır., Otobüs ile 5 durak sonra 10 dakika içinde metroya aktarma yapabilirsiniz. Ücret 5 TL., ] for txt in sample_texts: print(f\n原文: {txt}) highlighted, fares highlighter.highlight_fares(txt) print(f高亮后: {highlighted}) print(f提取的票价: {json.dumps(fares, indent2, ensure_asciiFalse)})4.3 代码解读与核心逻辑这个简易实现包含了几个关键部分模式初始化 (__init__): 加载了匹配价格和上下文关键词的正则表达式。文本规范化 (_normalize_text): 尝试使用tr库进行正确的土耳其语小写转换这是提高匹配准确率的第一步。候选查找 (find_fare_candidates): 扫描文本找出所有可能是价格数字货币和上下文关键词的片段并记录其位置和原始文本。上下文评分 (_calculate_context_score): 这是消歧的核心。对于一个价格候选检查其周围一定窗口默认100字符内所有的上下文关键词。距离越近的关键词对价格的“票价属性”贡献越大。通过加权求和计算出一个“最终得分”。高亮与结构化 (highlight_fares): 设定一个阈值如15分只有最终得分高于阈值的价格才被认定为“票价”。然后用HTML的mark标签包裹这些价格生成高亮文本。同时提取价格、货币和周围文本片段形成结构化数据。实操心得阈值threshold的选择需要根据实际语料进行调优。可以先收集一批标注好的文本哪些数字是票价哪些不是然后运行脚本观察不同阈值下的精确率Precision和召回率Recall选择一个平衡点。一开始可以设得保守一些高阈值确保高亮出来的基本都是对的避免用户被错误信息干扰。5. 性能优化与高级功能拓展基础版本跑通后我们可以从以下几个方面增强其实用性和鲁棒性。5.1 处理更复杂的票价表述现实文本中票价表述可能更复杂区间票价“5-15 TL arası”5到15 TL之间。我们的正则需要扩展以捕获连字符和“arası”这样的词。# 新增模式 interval_pattern re.compile(r(\d[\d.,]*)\s*-\s*(\d[\d.,]*)\s*(TL|₺|lira)\s*(arası|arasında)?, re.IGNORECASE)条件票价“İlk 10 km 5 TL, sonraki her km 0.50 TL”前10公里5 TL之后每公里0.50 TL。这需要解析两个价格及其关联的条件短语。我们可以通过寻找“ilk”首先、“sonraki”后续、“her”每等序列词并尝试将后面的价格数字与前面的条件绑定。免费描述“65 yaş üstü ve 0-6 yaş ücretsiz”65岁以上和0-6岁免费。这里没有价格数字但有关键的票价信息“ücretsiz”免费。我们的系统应该也能识别并高亮“ücretsiz”或者将其作为一种特殊的“票价实体”价格为0输出。5.2 集成机器学习进行消歧当规则变得过于复杂时可以考虑引入轻量级机器学习模型。我们并不需要训练一个庞大的NER命名实体识别模型一个简单的文本分类模型可能就足够了。思路将任务转化为二分类问题。对于文本中每一个匹配到的数字及其周围固定窗口的上下文例如前后各5个词判断它是否是“票价”。数据准备手动标注一批数据每个样本是(context_window, label)label为1是票价或0不是票价。特征工程可以提取如下特征数字本身是否匹配货币模式布尔。上下文窗口中特定关键词来自我们的词典的出现次数词袋模型。数字在句子中的位置。数字前后特定词性的词如果做了词性标注。模型训练使用如逻辑回归、随机森林或简单的神经网络如FastText进行训练。Scikit-learn足以完成这个任务。集成到流程在highlight_fares函数中对于每个通过基础正则匹配到的价格候选用训练好的模型预测其概率将概率值作为final_score的一部分或者直接使用模型预测结果代替阈值判断。这种方法比纯规则更灵活能学习到更复杂的上下文模式但需要标注数据。5.3 提升处理速度与部署考量如果处理海量文本如爬取新闻网站性能很重要。正则表达式优化将多个正则表达式用|合并编译成一个减少多次扫描。使用re.finditer而不是re.findall来获取位置信息。缓存机制对于频繁出现的固定文本片段如“Öğrenci bileti”可以缓存其分析结果。异步处理如果作为Web服务部署例如提供一个API端点使用异步框架如FastAPI async/await来处理并发请求。输出格式多样化除了HTML可以提供纯文本位置索引[start, end]列表、JSON-LD一种结构化数据格式利于搜索引擎理解或自定义的标记语言输出方便不同下游系统集成。6. 常见问题与实战排坑记录在实际使用和开发类似工具的过程中我遇到过不少典型问题这里记录一下。6.1 匹配不准确或漏匹配问题有些票价没有被高亮或者不是票价的内容被错误高亮。排查检查正则表达式是否覆盖了所有数字格式例如文本中是“5.5 TL”小数点用点而你的正则只匹配了“5,5 TL”使用在线的正则表达式测试器如regex101.com针对你的样例文本进行调试。检查文本规范化大小写转换是否正确特别是“İ”和“i”。打印出规范化后的文本看看关键词是否被正确转换为小写形式。检查上下文窗口window_size参数是否设置合理对于长句子可能需要增大窗口。或者更好的办法是以句子为单位进行处理而不是在整个段落上滑动窗口。检查阈值threshold是否太高导致漏报或太低导致误报需要根据验证集调整。解决建立一个小的测试用例集包含各种正例和反例。每次修改代码后都跑一遍测试集确保准确率和召回率在可接受范围内。6.2 处理包含换行符和HTML标签的文本问题从网页爬取的文本可能包含br、p标签或大量的nbsp;这会影响分词和匹配。解决在预处理阶段使用BeautifulSoup或lxml库提取纯文本或者用简单的正则re.sub(r[^], , text)移除HTML标签。将多个连续空格和换行符替换为单个空格。6.3 数字与单位的错误解析问题价格“1.500,50 TL”被解析为1.500一千五和50 TL。这是因为正则或清洗逻辑错误地将千位分隔符和小数点混淆了。解决土耳其的数字格式是千位分隔符是点.小数点是逗号,。在清洗字符串时必须先移除千位分隔符点再将小数逗号替换为点。顺序不能错。更稳健的方法是先判断格式如果数字部分包含逗号且逗号后只有1-2位数字则逗号是小数点否则点可能是千位分隔符。可以编写一个专用的parse_turkish_number函数。def parse_turkish_number(num_str: str) - float: 解析土耳其语格式的数字字符串如 1.500,50 或 7,67 num_str num_str.strip() if , in num_str: # 可能有小数部分 parts num_str.split(,) if len(parts) 2 and len(parts[1]) 2: # 标准格式逗号是小数点 integer_part parts[0].replace(., ) return float(integer_part . parts[1]) else: # 异常情况可能逗号是千位分隔符在土耳其语中不太可能谨慎处理 # 这里简单尝试移除所有点将第一个逗号替换为点 cleaned num_str.replace(., ) if , in cleaned: cleaned cleaned.replace(,, ., 1) return float(cleaned) else: # 无逗号直接移除千位分隔符 return float(num_str.replace(., ))6.4 部署为服务时的注意事项如果你打算将这个东西封装成一个微服务API设计设计简洁的REST API例如POST /highlight接受{“text”: “...”}返回{“highlighted_html”: “...”, “fares”: [...]}。错误处理做好输入验证和异常捕获返回清晰的错误信息。性能监控记录处理时长、请求量监控在高并发下是否出现内存泄漏或性能下降。版本管理关键词词典和规则可能会更新API最好有版本号如/v1/highlight。这个“Fare-imleci-vurgulayici”项目从一个独特的细分需求出发展示了如何将领域知识土耳其语、公共交通票价规则与基础的文本处理技术结合解决一个实际痛点。它的价值不在于用了多高深的技术而在于对问题域的深刻理解和细致实现。对于开发者而言复现或借鉴这个项目的思路完全可以扩展到其他语言或其他垂直领域的信息提取场景比如从中文文本中提取快递价格、从英文合同中提取关键条款金额等。核心逻辑是相通的定义模式、理解上下文、消除歧义、结构化输出。