Python标准库实战:打造可配置的行程规划命令行工具

Python标准库实战:打造可配置的行程规划命令行工具 说走就走去石家庄在多数人眼里并不需要代码参与订票、看天气、把收藏表里的地点按位置串起来剩下的交给临场发挥。但真做过一次临时出发的人会发现所谓“说走就走”其实是一连串隐藏的决策任务去哪个片区、几点出门、哪些地方周一不开放、同伴想逛博物馆还是吃东西、路线会不会在同一片区反复折返。信息越零散临时做决定越容易产生选择疲劳最后只能靠“到地方再说”收场。这篇博客不讨论具体旅行攻略而是把“说走就走去石家庄”当作一个可复现的工程场景手动打造一个极简的saygo行程生成命令行工具。整个项目只用 Python 标准库完成示例数据采用石家庄的城市片段能根据日期、偏好标签和时段槽生成可读的当日行程。代码不依赖第三方包跑完可以直接替换城市数据理解“配置驱动逻辑”这个思路后也能迁移到其他规划类小工具上。1. 先理解“说走就走”的行程规划到底在算什么旅行规划看起来偏经验不太像软件问题但拆开后就变得很规则人有一整天的时间窗口时间窗口被切分成若干个时段槽每个时段至少需要安排一个候选地点地点有自己的分类、推荐指数和可能出现的闭馆日同行的几个人又有各自的偏好。把这些写成约束条件就会得到经典的选择问题。1.1 临时决策的三个瓶颈临时决定去某座城市最大的阻力不是距离而是决策成本。第一个瓶颈是候选地点太多。打开社区、笔记和地图应用能看到几十个推荐点但一天只有有限行程。第二个瓶颈是偏好不一致。有人偏爱历史类场所有人只想找合适拍照或吃饭的落点单独看每个候选地点都合理合成一天就容易冲突。第三个瓶颈是日期信息最容易漏。周一闭馆、临时维护、时间段预约每一项都能让临场到门口的计划瞬间失效。把三个瓶颈转成技术需求后结论很明确需要的不是一张写死的行程表而是一个能接受日期和偏好输入的过滤器。输入是城市候选地点集合输出是符合日期条件、同时在有限时段槽里完成排序的路线组合。1.2 把行程规划抽象成数据流用更工程化的语言描述行程规划可以分成三个数据阶段。第一个阶段是准备阶段城市数据被序列化到 JSON 文件里记录地点名称、所属区域、开放信息、标签、建议游玩时长等字段。第二个阶段是过滤阶段程序读取系统日期或用户传入日期过滤掉闭馆地点读取偏好参数优先保留标签命中的候选。第三个阶段是生成阶段把剩余地点按时段槽放入日程同一个地点不能重复出现最后渲染成终端文字、Markdown 或 JSON 三种输出格式。这个处理链路可以用一张映射关系总结。旅行场景工程表达落地字段或参数一天被切分成几个时段时段槽 slot配置中的 slots 列表具体的景点或餐厅候选地点 placeplaces 列表中的元素周一闭馆等日期限制闭馆日期集合closed_weekday 字段成员想逛历史或拍照偏好标签--prefer参数同一地点不能安排两遍唯一 ID 去重place.id 集合某个时段放几个点槽位容量slots.count这样处理后原本依赖经验和口头协调的流程就变成一条数据和规则共同驱动的流水线。用户只需要改参数不需要改动核心代码。2. 环境与项目骨架设计为了让项目保持低门槛saygo对外只有一套标准库不引入 Flask、FastAPI 或第三方 CLI 框架。适用于学习环境也方便在需要时替换成更重的生产实现。2.1 运行环境要求写这个脚本时可以按以下环境准备。不同 Python 小版本对代码影响不大但建议不要低于 3.9因为会使用类型注解、fixture 等新特性。软件或工具版本建议用途Python3.9 或更高运行 CLI、测试和校验逻辑操作系统Windows / macOS / Linux跨平台处理路径和文件名包管理venv隔离依赖避免污染系统 PythonGit可选保存配置和代码版本在命令行先确认 Python 与 pip 已经可用。python --version python -m pip --version如果python在 Windows 上没有正确进入环境可以尝试py --version或py -3 -m venv .venv。确认完成后创建虚拟环境并激活后续所有操作都在该环境中进行。mkdir saygo cd saygo python -m venv .venv # Windows PowerShell .venv\Scripts\Activate.ps1 # macOS / Linux source .venv/bin/activate一个很常见的坑是在 Windows 命令行里直接执行activate而不是Activate.ps1导致提示无法识别。这里只需要记住venv 激活指令在不同 shell 中名称不同。2.2 目录结构设计这个项目将数据、逻辑、命令入口和测试分离到不同文件后面的代码补全会更轻松。一套常用结构如下。saygo/ ├── data/ │ └── cities/ │ └── sjz.json ├── saygo/ │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── planner.py │ └── output.py ├── tests/ │ └── test_planner.py └── README.mddata/cities/sjz.json是演示用的石家庄城市数据。saygo/planner.py负责数据校验、过滤、排序和计划生成。saygo/output.py专门承担渲染输出。saygo/cli.py负责解析命令参数。saygo/__main__.py让项目能通过python -m saygo直接运行。tests/test_planner.py验证核心逻辑。创建目录时可以使用以下命令也可以按文件名逐个创建空文件。mkdir -p data/cities mkdir -p saygo mkdir -p tests之所以坚持“数据与代码分离”是因为行程数据以后会不断变化。把地点直接写死在代码里会形成维护黑洞每次增加一个地点或调整闭馆规则都要改程序。JSON 文件只是静态配置更新成本低也更容易让非开发同事参与整理。3. 用 JSON 给石家庄准备一份可读的“行程素材”一个能让工程跑起来的数据文件不能只塞几个地名。它需要表达清楚城市名、时段槽、候选地点和当前数据的说明信息。3.1 字段设计与含义在sjz.json中准备两层结构。顶层是城市元信息和 slots 时段配置下一层是places候选地点列表。地点字段建议按以下表格设计。字段类型含义示例idstring唯一地点 IDp_hebo_museumnamestring展示名称河北博物院示例数据areastring城市片区主城区slotslist适合安排的时段槽[morning]openinglist开放时间段[09:00, 17:00]closed_weekdaylist闭馆星期编号[0]表示周一duration_minint建议停留分钟90taglist偏好标签[历史, 室内]scoreint基础推荐指数95为了避免初学者在“星期编号”上出错这里特别说明代码内部使用 Pythondate.weekday()返回值周一为0周日为6。如果某个馆周一闭馆closed_weekday应写成[0]而不是常见人类习惯里的[1]。如果从其他系统复制数据必须先确认这一层语义。3.2 石家庄样例配置下面是一个用于演示的最小配置里面包含几个地点片段没有打算穷尽真实信息。任何人运行下面的代码前都应当把mode字段的精神贯彻到自己的数据里这是一份演示数据真实出行要以官方渠道为准。{ city: 石家庄, meta: { title: 石家庄一日游示例数据, mode: demo, notice: 该文件仅用于演示生成器逻辑不代表实时营业信息与实时交通状态 }, slots: [ { id: morning, name: 上午, count: 1 }, { id: noon, name: 午饭, count: 1 }, { id: afternoon, name: 下午, count: 1 }, { id: evening, name: 晚上, count: 1 } ], places: [ { id: p_hebo_museum, name: 河北博物院示例数据, area: 主城区, slots: [morning, afternoon], opening: [09:00, 17:00], closed_weekday: [0], duration_min: 120, tag: [历史, 室内], score: 95 }, { id: p_zhengding, name: 正定古城片区示例数据, area: 正定, slots: [morning, afternoon, evening], opening: [08:30, 21:00], closed_weekday: [], duration_min: 150, tag: [历史, 户外, 摄影], score: 90 }, { id: p_restaurant, name: 本地特色餐厅示例数据, area: 主城区, slots: [noon], opening: [11:00, 21:00], closed_weekday: [], duration_min: 60, tag: [美食], score: 85 }, { id: p_park, name: 城市公园夜游示例数据, area: 主城区, slots: [evening], opening: [06:00, 22:00], closed_weekday: [], duration_min: 60, tag: [户外, 摄影], score: 70 } ] }slots中每个槽位都有一个count表示这个时段最多放几个地点。项目并非要解决商旅级导游问题只需要做一组极简调度。每个地点可以出现在多个合适的时段槽里但最终生成时同一 ID 只会被选中一次避免同一天重复安排同一地点。3.3 为什么数据加载前要校验如果直接在代码里遍历 JSON一旦字段缺失或类型错误程序只会在用到该字段时抛出一个晦涩的KeyError或TypeError。提前用校验函数检查必填项可以把错误信息变成更明确的提示例如“地点 id 为 xxx 的配置缺少 slots 字段”。对 JSON 文件做校验不是多余步骤它保证后面的排序逻辑拿到的是干净的结构化数据。输入数据质量一旦失控输出计划只会让使用者更困惑。注意在接入真实数据源之前始终把城市 JSON 当作“可替换的测试夹具”。不要在演示数据里加入过多无法确认的营业时间、交通耗时和价格信息否则生成的计划容易造成误导。4. 核心逻辑加载数据、过滤闭馆地点、按偏好排序数据处理采用四步设计先定义状态类再加载 JSON接着过滤最后生成计划。每一步只做一件事方便单元测试单独覆盖。4.1 用 dataclass 管理地点状态planner.py使用dataclass保存地点状态让字段访问比字典取值更安全。Python 3.9 的 dataclass 能减少大量样板代码也让 IDE 补全更友好。from __future__ import annotations import json from dataclasses import dataclass, field from datetime import date, datetime from pathlib import Path dataclass class SlotConfig: id: str name: str count: int dataclass class Place: id: str name: str area: str slots: list[str] opening: list[str] closed_weekday: list[int] duration_min: int tag: list[str] score: int 50 def is_open_on(self, check_date: date) - bool: return check_date.weekday() not in self.closed_weekday def is_preferred(self, preferences: list[str]) - bool: tags set(self.tag) prefs set(preferences) return bool(tags.intersection(prefs))is_open_on的关键点是把日期判断封装在地点内部避免在生成函数里到处写weekday()。is_preferred则比较地点标签与用户偏好之间是否存在交集。4.2 加载与校验加载函数需要接收Path对象。读取时显式使用encodingutf-8防止 Windows 默认编码或某些环境变量导致中文乱码。def load_city(config_path: Path) - dict: if not config_path.exists(): raise FileNotFoundError(f无法找到城市配置文件: {config_path}) raw json.loads(config_path.read_text(encodingutf-8)) validate_city_config(raw) return raw def validate_city_config(raw: dict) - None: required_top {city, slots, places} missing_top required_top - set(raw.keys()) if missing_top: raise ValueError(f城市配置缺少顶层字段: {sorted(missing_top)}) for slot in raw[slots]: required_slot {id, name, count} missing required_slot - set(slot.keys()) if missing: raise ValueError(f时段槽字段缺失: {sorted(missing)}) place_ids: set[str] set() for place in raw[places]: required_place {id, name, area, slots, opening, closed_weekday, duration_min, tag} missing required_place - set(place.keys()) if missing: raise ValueError(f地点 {place.get(id, unknown)} 缺少字段: {sorted(missing)}) if place[id] in place_ids: raise ValueError(f地点 id 重复: {place[id]}) place_ids.add(place[id])校验函数只检查结构性问题不判断数据真伪。“河北博物院周几闭馆”这种数据真伪必须依赖权威来源程序保证不了。4.3 过滤与排序设计过滤阶段不直接修改原始数据而是产生一个可靠的候选列表。算法可以这样理解先取某时段槽能容纳的地点再排除当天闭馆地点再排除已经选过的地点最后按“推荐值 偏好加成”降序排序。def build_plan( raw: dict, preferences: list[str], plan_date: date, ) - list[dict]: slots [SlotConfig(**slot) for slot in raw[slots]] places [Place(**place) for place in raw[places]] chosen_ids: set[str] set() final_plan: list[dict] [] for slot in slots: candidates [p for p in places if slot.id in p.slots] candidates [p for p in candidates if p.is_open_on(plan_date)] candidates [p for p in candidates if p.id not in chosen_ids] candidates.sort( keylambda p: _rank_place(p, preferences), reverseTrue, ) picked candidates[: slot.count] if not picked: continue for place in picked: chosen_ids.add(place.id) final_plan.append( { slot_id: slot.id, slot_name: slot.name, place: { id: place.id, name: place.name, area: place.area, opening: place.opening, duration_min: place.duration_min, tag: place.tag, score: place.score, }, } ) return final_plan def _rank_place(place: Place, preferences: list[str]) - float: score float(place.score) if not preferences: return score matched len(set(place.tag).intersection(set(preferences))) return score matched * 20排序公式很简单基础分加上命中标签数量乘 20。它要体现的是“偏好能改变默认排序”但不完全推翻质量因素。若一个地点基础分太低即使匹配一次偏好也很难超过高分通用候选。这符合“行程规划既要照顾喜好又不能忽略质量”的直觉。4.4 为什么要用“选中 ID 集合”去重同一个地点可以出现在多个slots数组中这在 JSON 里是合理的。比如“河北博物院”既能放在“上午”又能放在“下午”但如果上午已经选中下午再出现就必须跳过。chosen_ids集合就是为这个约束服务的。集合查找在数据量不大时非常快同时用 ID 而不是名字判断也降低了名称显示变更造成的重复风险。重复推荐会带来很差的体验例如一天里同一个商场出现在中午和晚上普通人一看就觉得程序不聪明。5. 命令行入口与三种输出格式数据加载和计划生成本身不依赖终端需要 CLI 把它们串起来让使用者不用写 Python 代码就能生成行程。5.1 参数设计在cli.py中使用标准库argparse。命令行参数不能太复杂设计方案如下。参数类型必填作用--config路径是指定城市 JSON 文件--date日期字符串否判断闭馆信息默认当天--prefer多值字符串否偏好标签如历史、美食--format字符串否输出 text / md / json默认 text--output路径否写入文件不传则打印到标准输出argparse的多值参数会让--prefer变得直观调用方式为--prefer 历史 室内两个值会同时进入偏好集合。import argparse from datetime import date from pathlib import Path from .planner import load_city, build_plan from .output import render_text, render_markdown, render_json def parse_args(argvNone) - argparse.Namespace: parser argparse.ArgumentParser(description说走就走行程计划生成器) parser.add_argument(--config, typePath, requiredTrue, help城市配置 JSON 路径) parser.add_argument(--date, typedate.fromisoformat, defaultNone, help计划日期默认今天) parser.add_argument(--prefer, nargs*, default[], help偏好标签多值传递) parser.add_argument(--format, choices[text, md, json], defaulttext) parser.add_argument(--output, typePath, defaultNone, help输出文件路径) return parser.parse_args(argv) def main(argvNone) - None: args parse_args(argv) plan_date args.date or date.today() raw load_city(args.config) plan build_plan(raw, args.prefer, plan_date) if args.format text: content render_text(plan) elif args.format md: content render_markdown(plan) else: content render_json(plan) if args.output: args.output.write_text(content, encodingutf-8) print(f已写入: {args.output}) else: print(content)__main__.py只是薄薄一层让python -m saygo有入口。from .cli import main if __name__ __main__: main()5.2 输出模块与 Markdown 转义输出功能独立成output.py。终端文本适合快速预览Markdown 适合转成文件保存JSON 适合交给其他程序继续处理。import json def render_text(plan: list[dict]) - str: lines: list[str] [] for row in plan: place row[place] opening -.join(place[opening]) lines.append( f[{row[slot_name]}] {place[name]} f{opening} {place[duration_min]}分钟 ) lines.append(f 标签: {, .join(place[tag])}) return \n.join(lines) def render_markdown(plan: list[dict]) - str: lines: list[str] [ # 当日行程计划, , 该文件由命令行工具生成仅供活动安排参考。, , ] current_slot: str | None None for row in plan: if row[slot_name] ! current_slot: current_slot row[slot_name] lines.append(f## {_esc_md(current_slot)}) lines.append() place row[place] lines.append(f- {_esc_md(place[name])}{place[area]}) lines.append(f - 开放时间{_esc_md(-.join(place[opening]))}) lines.append(f - 建议停留{place[duration_min]} 分钟) lines.append(f - 标签{_esc_md(, .join(place[tag]))}) lines.append() return \n.join(lines) def _esc_md(value: str) - str: for char in #*_[]: value value.replace(char, \\ char) return value def render_json(plan: list[dict]) - str: return json.dumps(plan, ensure_asciiFalse, indent2)Markdown 转义函数虽然简单但很实用。当地点名称、标签或备注中出现#、*、_、[等字符时不转义会破坏标题、强调或链接语法导致生成文件在编辑器里格式混乱。6. 运行验证与常见问题排查一个说走就走的小工具如果只“能启动”但输出混乱那便只完成一半。需要补测试再走一遍正常输入、日期约束和偏好约束。6.1 添加最小单元测试在tests/test_planner.py里添加三个测试分别覆盖周一闭馆、同日去重和偏好加成。import unittest from datetime import date from pathlib import Path from saygo.planner import build_plan, load_city BASE_DIR Path(__file__).resolve().parents[1] class PlannerTest(unittest.TestCase): def setUp(self): self.raw load_city(BASE_DIR / data / cities / sjz.json) def test_close_on_monday(self): monday date(2025, 3, 3) # 该日期是周一 plan build_plan(self.raw, preferences[], plan_datemonday) place_names [row[place][name] for row in plan] self.assertNotIn(河北博物院示例数据, place_names) def test_build_plan_contains_place_at_most_once(self): plan build_plan(self.raw, preferences[], plan_datedate(2025, 3, 8)) place_ids [row[place][id] for row in plan] self.assertEqual(len(place_ids), len(set(place_ids))) def test_preference_adds_more_visible_places(self): plan build_plan(self.raw, preferences[美食], plan_datedate(2025, 3, 8)) self.assertTrue(any(美食 in row[place][tag] for row in plan)) if __name__ __main__: unittest.main()第一个测试固定选择某个周一确认闭馆数据不会被安排进计划第二个测试检查 ID 不重复第三个测试验证偏好标签会真的影响候选结果。测试不是用来看输出漂不漂亮而是看规则是否正确执行。6.2 命令行运行运行测试python -m unittest discover tests -v手动生成一份文本行程python -m saygo --config data/cities/sjz.json --format text如果没有传--date程序使用系统当天日期。观察终端输出一个可能的运行结果类似[上午] 河北博物院示例数据 09:00-17:00 120分钟 标签: 历史, 室内 [午饭] 本地特色餐厅示例数据 11:00-21:00 60分钟 标签: 美食再使用偏好参数查看差异python -m saygo --config data/cities/sjz.json --prefer 摄影 历史 --format md --output plan.md打开生成的plan.md会看到历史类标签地点可能被排到更靠前的位置同时室内与户外地点在一起出现。这个行为告诉我们加入偏好前后输出发生变化是预期结果而不是程序随机化。6.3 三个与主题强相关的常见坑运行同样的命令不同的人会遇到不同问题。这里把表现、原因和解决方式列成一张速查表。问题现象常见原因检查方式解决建议提示找不到sjz.json当前工作目录不是项目根目录--config使用了相对路径在项目根目录运行pwd再查看data/cities是否存在路径用绝对路径或在项目根目录下使用上述示例命令JSON 文件包含中文且读取报编码错误用默认编码读取文件Windows 下可能不是 UTF-8查看错误堆栈是否指向UnicodeDecodeError代码中显式使用encodingutf-8写文件同样指定今天是周一但计划里仍然出现闭馆地点把闭馆星期编号写错了[1]被当成周一查看 JSON 中closed_weekday的值和 Pythondate.weekday()定义周一到周日对应0到6周一闭馆写成[0]除了表里的三个问题还有两个坑值得展开。第一个坑是命令行偏好与 JSON 标签不一致。数据里写的是“历史”终端传成了“历史 ”或“社科”大小写或空格不同会导致偏好几乎不生效。程序内部没有强大的模糊匹配本方案采用精确匹配所以在设计标签时就要形成一致性规范例如全用中文名词去掉前后空格统一使用逗号分隔不使用同义词。第二个坑是数据中某地点被配置为适合多个时段槽后如果计数设置过大排序结果显示很多地点堆在同一片区实际体验并不好。该工具排序只基于标签与分数没有考虑地理真实通勤距离。生产环境中要接入地图路线时长后才能把位置因素加进排序。这不应该是 demo 阶段最优先处理的问题但需要在扩展阶段及早说明。第三个和主题强相关的坑更多出现在日期语义上。如果计划执行时用户不传日期程序默认读取当天日期在不同时区或跨天后结果可能改变。一个典型场景是用户临睡前生成明天行程但系统时钟已经跨过 0 点日期自动变成新一天导致周一闭馆规则误触发。对于这个工具来说建议在命令行里明确传入计划日期让数据与程序状态确定。注意不要在生成行程文件后不复查直接分享。程序只是演示生成器它不理解真实交通管制、临时预约名额和历史建筑临时关闭这些信息必须由使用者确认。7. 从本地生成清单变成生产可用的出行服务当前项目更像一个本地脚本不适合直接对外提供服务。把一个演示工具变成生产可用的“出行助手”需要重新设计数据来源、缓存、接口和观测。7.1 替换静态 JSON 数据源本地 JSON 没有实时性。把路线规划推向真实场景至少需要接入三个数据源地点 POI 目录、机构开放状态、天气与交通信息。接入方式不复杂较合理的模式是把数据源封装成 Repository 接口核心生成器仍然只依赖结构相同的 Place 对象。改动集中在外部采集层build_plan的算法不需要大幅变化。数据能力演示阶段生产阶段地点列表手写 JSON对接官方地图开放平台目录服务开放时间人工维护查询场馆公告或开放 API天气不处理接入天气服务雨天提升室内标签权重位置距离不可用调用路线规划 API 获取真实时长日期规则weekday()增加节假日与特例维护7.2 缓存、监控与版本回滚接入外部服务之后最明显的生产风险是网络不确定性。天气接口可能超时地图服务可能限流这些状态不该阻断整个生成器。开发时可以在请求层加超时和熔断响应过期时回退到最近一次缓存快照。回滚策略很简单保留上次成功的 JSON 缓存文件外部服务不可用时让用户使用缓存。生成器本身没有用户系统但日志不能少。至少需要记录三个维度请求偏好、数据命中数量、最终输出格式。在 CLI 工具升级为 Web API 后这些记录能帮助定位“为什么某次推荐结果不符合预期”。7.3 可复用的“生产化”检查清单不管做到哪一步都要有一张检查单避免上线前漏项。检查项学习环境确认生产环境建议数据来源本地 JSON 可读确认数据授权、频率和字段版本日期规则周一闭馆能过滤处理节假日、临时闭馆、时区配置输出文件能打印到终端文件写入加锁避免并发覆盖偏好标签标签完全匹配增加同义词表或轻量分词网络依赖无超时、重试、缓存、熔断异常提示能看到堆栈调用方收到结构化错误信息验证方式单元测试通过增加接口测试与人工复核流程真实出行样例输出好看即可必须二次确认开放时间与预约要求7.4 更适合新手练习的三个扩展方向这个工具不需要一上来就上微服务架构最顺畅的练习路径如下方向一是把同一个底层函数传给 Web 层。可以写一个最简 FastAPI 服务把build_plan暴露为 HTTP 请求但不要在这个阶段引入数据库和复杂鉴权。先体会“纯函数转化”与“网络层”的边界。方向二是增强排序函数引入偏好的权重和场所的分类优先级。改造时注意保留旧算法的单元测试让重构有安全网。方向三是接入真实的公开信息源把本地 JSON 改成一份每天缓存更新的数据表观察数据新鲜度对计划质量的影响。对新手最值得做的练习不是马上扩展而是先给自己写一份“石家庄以外”的城市 JSON。任意输入一座城市的几条候选地点确认脚本能正常生成计划后再逐步增加字段和算法。这个动作能把“读代码”变成“改数据、改规则、验证输出”的完整闭环。真正难的不是写几行 Python而是设计一套在信息有限时还能保持可预测、可排查、可回退的决策流程。