ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

web_sync:用纯标准库实现一个网站目录同步工具(多站点 / 断点续传自愈 / 托盘常驻)

web_sync:用纯标准库实现一个网站目录同步工具(多站点 / 断点续传自愈 / 托盘常驻) 不少站点提供「index 目录列表」浏览教材、课件、镜像站、内网文件服务打开一个 URL 就是一片文件列表。浏览器逐层点开、右键另存几十个文件之后就没法维护了 —— 换台机器、隔两周再看无法判断哪些已经下过、哪些远端更新过。web_sync 就是针对这类站点写的同步工具递归抓取整站目录、断点续传、跳过已同步文件、可定时循环同时提供手动勾选下载的图形界面。整个项目只用 Python 标准库tkinter / urllib / html.parser / threading不引入任何第三方依赖可以用 PyInstaller 打成 12 MB 的单文件 exe。图 1主界面。左侧是多站点导航树右侧是当前目录的详情列表名称 / 修改日期 / 类型 / 大小点表头可排序底部状态栏给出项目数与传输进度。项目说明语言 / 依赖Python 3.8纯标准库无第三方依赖界面tkinter自绘圆角控件参考 Windows 10 文件资源管理器托盘ctypes 直调Shell_NotifyIcon不用 pystray / pywin32打包PyInstaller onefile约 12.3 MB无控制台窗口版本1.1一、需求与定位工具面向的是「服务器把目录内容渲染成 HTML 列表」的站点不针对某个特定站点的模板。判断能否使用只有一条标准页面里存在指向文件和子目录的a href链接。两类工作模式并存自动同步模式。选定站点根地址后一键全同步递归遍历全站、断点续传、跳过已同步文件可按设定间隔定时循环。远端文件被替换过大小变了 / ETag 变了会识别为「本地是旧版」并整份重下。手动下载模式。加载远程目录树在右侧列表里勾选需要的目录或文件再下载适合只取一部分内容的场景。二、界面与使用界面按 Windows 10 文件资源管理器的习惯组织顶部双行命令栏 圆角地址栏左侧导航树右侧详情列表底部状态栏。按钮作用后退 / 前进 / 上级 / 进入目录导航等效 Alt←、Alt→、Backspace、回车刷新重新读取当前目录F5排除规则编辑当前站点的排除规则命中项不下载全选 / 反选 / 清除批量操作勾选状态CtrlA 全选一键全同步按当前站点根地址递归同步不弹窗下载选中只下载勾选中的条目停止中止当前传输已下载部分保留为断点定时扫描按设定间隔循环同步间隔 0 表示只跑一轮添加站点 / 生成本地索引 / 打开目录站点管理、生成离线 index.html、打开本地下载目录选择方式单击逐项切换 ☑/☐按住拖动可以范围多选CtrlA 全选。双击文件夹进入双击文件直接下载。站点配置持久化在~/.web_sync/sites.json通用设置线程数、关闭行为在~/.web_sync/settings.json。每个站点各自保存自己的排除规则与筛选条件互不干扰。三、模块结构与技术选型图 2模块分层与数据落点。界面层与核心层单向依赖托盘与打包链路都是可选件 —— 任意一环失败都不影响主程序。文件行数职责web_sync.py1541核心目录解析、递归遍历、目录探针、下载器、日志、本地索引同时是程序入口web_gui.py3005tkinter 界面导航树、详情列表、筛选栏、排除规则窗口、任务调度win_tray.py520任务栏通知区域图标纯 ctypesmake_icon.py127纯数学绘制多尺寸 ICO不依赖 Pillowweb_sync.spec—PyInstaller 打包配置version_info.txt—exe 的 Windows 版本资源作者 / 版本 / 版权tests/test_author.py—作者署名的回归测试防止后续改动丢掉某个落点选型上做了三个取舍。不引入第三方库。网络请求用urllib.requestHTML 解析用html.parser并发用concurrent.futures。代价是解析器要自己写收益是打包后的体积、启动速度与依赖风险都可控 —— 标准库的行为在 Python 大版本之间足够稳定。解析器写成通用的。只认「页面里有a href指向文件或目录」这一条不绑定站点的目录页模板。GenericIndexParser收集全部链接同时标记目录线索尾斜杠、folder类名图标、alt[DIR]、图片src含folder。界面用 tkinter 而不引 Qt。代价是圆角按钮、悬停、拖动多选这类交互要拿 Canvas 自己画收益是整个程序没有外部运行时依赖打包即用。四、目录识别链接不带尾斜杠的文件夹这是实际使用中踩得最深的一个坑。同一个站点的目录页模板会把文件夹写成带尾斜杠的形式而卡片页、导航区可能写成不带尾斜杠的形式。两种写法在 HTML 里都是普通链接特征上只差一个字符。目录页模板a hrefimages/images//a → 一眼是目录 卡片页 a hrefcourse-materials/books课程教材/a → 没有尾斜杠解析器如果只认尾斜杠与文件夹图标第二种写法会被当成「文件」处理请求回来的目录列表页被存成一个名为books的 HTML 文件而它下面的整棵子树永远不会被遍历、里面的文件也永远下不来。这个问题的隐蔽之处在于它不报错 —— 只是安静地少下了一整个文件夹。图 3粗筛 探针两步确认。粗筛只看链接形状不发请求探针让服务器自己回答「这是不是目录」。粗筛_href_maybe_dir只看链接形状不发请求四条同时满足才算可疑与父页同站点netloc相同不带查询串与锚点?、#都没有末段没有扩展名.不出现在首字符之后末段非空形如/games这种「无扩展名的页面」也会被放进可疑集合由探针定夺 —— 宁可多问一次也不要漏掉一个文件夹。探针probe_dir对可疑项发一次请求判据按可信度排序序判据结论a请求后最终地址变成……/服务器把/dir重定向到/dir/是目录b返回体就是目录列表页含file-item/Index of/autoindex或pre里成片链接是目录c其它404、普通 HTML 页面、非 HTML 内容当文件判据 c 的取向是宁缺勿滥。把普通页面当目录去递归会把无关页面整片拖进来把目录当文件只是少下一点东西。两者代价不对等所以拿不准时一律按文件处理。命中后对条目做三处改写is_dir置真、dir_url记为跳转后的有效地址、把探针取回的页面存进page_cache。遍历该目录时直接复用缓存的页面省掉一次请求。界面上的导航树与右侧列表都通过entry_url()取地址保证「看到什么就下什么」。顺带修的另一个问题是条目去重。原来按显示名去重但站点会把过长的名字截断成「前 47 字 …」不同文件因此重名按名字去重会静默丢掉后面的条目。改成按href去重之后正常了。五、自动同步四种分流与三类自愈断点续传的经典事故是「把新版本的尾巴接到旧版本的头上」。本地有一个下载到一半的文件远端那份在此期间被替换过如果直接按本地大小续传得到的是一个前半段旧内容、后半段新内容的坏文件 —— 而且大小对得上事后极难发现。web_sync 用一份版本标识缓存来杜绝这种情况。每次下载成功含「已完整跳过」时把响应头里的ETag或Last-Modified记到~/.web_sync/cache/xx/sha1(url).json下次同步时拿它和远端比对。图 4本地已存在同名文件时的四条互斥分流以及下载阶段的重试与自愈。只有本地已存在文件时才发HEAD探测远端拿Content-Length与版本标识全新文件直接GET省掉一次往返请求。判定条件结论处理缓存里的版本标识与远端不一致远端已更新本地是旧版删掉本地整份重下本地大小 远端大小断点位置超出远端范围即 HTTP 416 的成因视为陈旧残留删掉整份重下本地大小 远端大小远端这份没变过跳过并把版本标识记回缓存本地大小 远端大小可能是断点残留也可能是旧版本有版本记录且一致则直接续传无记录则抽样比对尾部 64 字节确认是远端前缀才续传对不上就整份重下第三类自愈发生在下载阶段。若中途撞上 HTTP 416本地残留超出远端文件范围清除本地副本与版本记录后改整份重下 —— 用一个recovered标志保证只清一次避免反复清文件陷入循环。整个下载循环最多重试 3 次。续传本身用Range: bytesN-同时带上If-Range: 版本标识万一本轮期间远端又变了服务器会回200整份内容而不是206拼接不上的情况由它兜住。前缀抽样比对local_is_prefix是最后一道保险。取远端[existing-64, existing-1]这 64 字节与本地文件对应尾部比对一致说明本地确实是这次的断点残留续传安全不一致说明本地是别的版本或已损坏。服务器不支持范围请求回的不是206时返回None交给上层按原逻辑处理。六、遍历、排除与筛选递归遍历crawl用visited集合去重防止循环max_depth只作极端保护。每层调list_dir(url, upgradeTrue)取条目 ——upgradeTrue就是对条目跑一遍第四节说的目录探针。排除规则在两个阶段各过滤一次这是有意的遍历阶段按条目名剪枝命中的目录不会被递归直接省掉请求收集阶段collect_files按相对路径再过滤一次用于支持books/*.pdf这类带路径的规则 —— 遍历阶段拿不到完整相对路径。规则串用逗号 / 分号 / 空格分隔两种匹配语义规则形态匹配方式示例含通配符* ? [对条目名、完整相对路径、任一路径片段做fnmatch*.log、books/*.pdf、*cache*纯文本等于条目名、等于扩展名或命中路径中任一层目录名logs、html大小写不敏感。界面里预置了四组常用规则日志文件、网页索引、临时备份、系统垃圾可直接勾选。规则串写回配置前会做规范化去掉开头多余的./、按小写去重避免规则越积越多。筛选是另一维度作用于「已经遍历到的文件」只看或只下指定内容维度说明文件名 / 关键词逗号或空格分隔支持通配与子串name_hit同时对文件名与相对路径匹配修改时间预设今天、近 7 天、近 30 天、今年或自定义起止日期日期格式2025-01-01、2025-03、2025均可时间筛选依赖条目上的mtime字段由解析器从目录页里正则提取支持2025-01-01与01-Jan-2025两种常见格式提取不到时该条目按不命中处理。七、日志、缓存与本地索引打包成--windowed的 exe 之后stdout无处可去日志文件就成了唯一的排查依据。日志写在~/.web_sync/logs/web_sync-YYYY-MM-DD.log一行一条、UTF-8。级别定宽 4 字符便于对齐阅读INFO/OK/SKIP/STOP/FAIL/WARN。2026-09-29 09:12:31 [INFO] 识别为目录链接无尾斜杠https://…/books → https://…/books/ 2026-09-29 09:12:44 [OK ] 下载完成 https://…/00.tools.zip → E:\…\00.tools.zip 6.46 MB / 1.2s 2026-09-29 09:12:44 [SKIP] 跳过本地已完整https://…/01.运维工程师职业介绍.zip 333.19 KB 2026-09-29 09:13:02 [FAIL] 下载失败 https://…/12.pdf → E:\…\12.pdf ← HTTP Error 404: Not Found下载是多线程并发写日志用锁串行化。单日日志超过 8 MB 时把旧内容挪到.1备份超过 60 天的日志在启动时顺手清理。界面上的「 日志」按钮读这个文件失败明细窗口只筛FAIL/WARN行失败项可以直接重试。本地索引是复刻远程浏览体验的一步。同步完成后对本地目录树os.walk一遍每个目录写一份index.html列出子目录与文件、附大小与修改时间、带「返回上级目录」链接页脚署名。双击本地index.html就能像在站点上一样浏览已下载的内容不依赖网络。八、任务栏托盘纯 ctypes 实现托盘用 ctypes 直接调Shell_NotifyIcon不引入 pystray / pywin32。为了让它能被打进单文件 exe、且不干扰 Tk 主循环几处细节需要处理需要真实 HWND。注册自定义窗口类并建一个「无样式」的隐藏窗口不可见、不上任务栏、不进 AltTab把它作为托盘图标的消息接收窗口。窗口建在 Tk 主线程。Tk 的事件循环本身就是本线程的GetMessage/DispatchMessage托盘消息会自动派发到窗口过程无需另外起一个消息循环。监听TaskbarCreated消息。资源管理器崩溃重启后会广播这条消息收到时把图标重新加回去否则托盘图标会在重启后消失。这里有一个值得单独记下来的坑窗口过程WNDPROC里绝不能回调 Tcl/Tk。窗口过程是 ctypes 回调它被调用时 Tk 的 mainloop 已经释放了 GIL。此时如果在回调里调root.after或操作控件CPython 的线程状态会错乱并直接 abortFatal Python error: PyEval_RestoreThread: the function must be called with the GIL held, ... (the current Python thread state is NULL)打包成 exe 之后表现为ucrtbase.dll里0xc0000409fastfail闪退事件查看器里能看到 BEX64 崩溃记录。这类崩溃点离现场很远很难反查。因此模块的规矩是窗口过程里只做 Win32 调用、置标志位、往队列里塞数据绝不碰 Tcl也不弹TrackPopupMenu避免在回调里起嵌套消息循环。所有要交给上层的事件都排进内部队列由 UI 线程定期调poll()取走执行。traywin_tray.TrayIcon(tooltipweb_sync · 就绪,menu[(打开主界面,open_win),(-,None),# 分隔线(一键全同步,do_sync),(退出,quit_app)],on_clickopen_win,icon_pathrweb_sync.ico)defpump():# 必须在 UI 线程定期调用tray.poll()root.after(100,pump)pump()tray.notify(同步完成,新增 12跳过 3失败 0)tray.hide()# 退出前务必调用否则图标会残留界面侧的行为点最小化按钮会缩到托盘任务栏不留图标点关闭按设定询问「退出还是最小化到托盘」。托盘右键菜单动态生成失败计数非零时条目上会带数量。任何一步失败都只是没有托盘图标绝不影响主程序 —— 全部 try 兜底。九、打包成单文件 exe打包走web_sync.speconefile、无控制台、带图标与 Windows 版本资源。aAnalysis([web_sync.py],datas[(web_sync.ico,.)]if_HAS_ICONelse[],# 这两个模块是在函数/入口里按需 import 的显式声明避免被漏掉hiddenimports[web_gui,win_tray],...)web_gui与win_tray都不在顶层 importGUI 由入口按需加载、托盘失败要能降级PyInstaller 的静态分析找不到它们必须写进hiddenimports否则打出来的 exe 会「能启动但打不开界面」。打包环节最容易踩的坑是解释器缺 tkinter。托管版、精简版、通过python-build-standalone装的 Python 常常不带 Tcl/Tk。而 PyInstaller 对此完全静默打包照样「成功」产出的 exe 双击没窗口、也不报错、也没有日志。这是最难查的一类失败 —— 没有任何线索指向「tkinter 没打进去」。项目在三个地方做了拦截一是在web_sync.spec里前置断言。打包前先检查解释器能不能 importtkinter与_tkinter不能就带着解释器路径直接退出ifimportlib.util.find_spec(tkinter)isNoneor\ importlib.util.find_spec(_tkinter)isNone:raiseSystemExit([web_sync] 当前 Python 没有 tkinter打出来的 exe 会没有窗口。\nf 解释器{sys.executable}\n 请改用一个自带 Tcl/Tk 的完整版 Python 重打\n 或直接运行 build_exe.bat它会自动挑一个带 tkinter 的。)二是build_exe.bat自动挑解释器。依次试python、python3、py -3取第一个能import tkinter的一个都没有就明确报错并提示装完整版 Python而不是打出一个坏 exe。三是打包后做体积自检。带 Tcl/Tk 的 exe 约 12 MB没打进去的只有 9 MB 出头用字节数就能判for %%A in (dist\web_sync.exe) do set SZ%%~zA if %SZ% LSS 11000000 echo [警告] 这个 exe 偏小很可能没打进 Tcl/Tk请换一个带 tkinter 的 Python 重打。build_exe.bat与项目自带的build_exe.sh都是源码仓库的一部分不需要额外安装PyInstaller 本身需要额外装pipinstallpyinstaller构建步骤Windows 上双击build_exe.bat等价于下面三步cd /d 项目目录 python make_icon.py python -m PyInstaller --clean --noconfirm web_sync.specmake_icon.py生成web_sync.ico纯数学绘制 4×4 超采样抗锯齿直接写 32bpp BGRA 的 ICO含 16/24/32/48/64/128/256 多尺寸只有图标不存在时才需要跑--clean清掉上次的构建缓存--noconfirm跳过覆盖确认。产物在dist\web_sync.exe并且带上了 Windows 版本资源。资源可以在命令行读回来核对python _check_ver.py dist\web_sync.exe_check_ver.py用ctypes调GetFileVersionInfoSizeW/VerQueryValueW读 exe 的版本块sys.argv[1]是要检查的文件路径缺省值是dist/web_sync.exe。输出如下CompanyName zhb FileDescription web_sync —— 网站目录同步工具 (by zhb) FileVersion 1.1.0.0 ProductName web_sync (by zhb) LegalCopyright Copyright (C) 2026 zhb. All rights reserved. OriginalFilename web_sync.exe仓库里还有一份tests/test_author.py用unittest锁住作者署名的每一个落点源码文档头、界面标题、状态栏、version_info.txt、构建脚本 banner并断言旧署名没有残留 —— 改名时只需要动web_sync.py里的常量。python-munittest discover testsunittest是标准库自带discover会自动发现tests目录下的用例缺 tkinter 的解释器上 GUI 相关用例会自动 skip 而不是变红。十、命令行用法不启动界面也能用。不做图形界面但只想要「把某个目录整站拉下来」时命令行更省事。遍历并打印目录树不下载python web_sync.py--crawlurl[exclude][--filter 关键词][--since 日期][--until 日期]参数说明--crawl子命令只遍历并打印不下载url站点目录地址缺省用内置示例站点。不以/结尾时会自动补齐[exclude]可选的位置参数排除规则串也可改用--exclude/-x--filter/-f名称筛选逗号或空格分隔支持通配与子串--since起始日期2025-01-01/2025-03/2025都接受--until结束日期格式同上遍历并下载python web_sync.py--downloadurl[outdir][limit][threads][exclude]参数说明--download子命令遍历后下载url站点目录地址缺省用内置示例站点[outdir]本地输出目录缺省./web_sync_download[limit]只下前 N 个文件0表示不限用于先试一把[threads]下载线程数缺省 4[exclude]排除规则串--filter/--since/--until同上下载完成后会自动对输出目录生成index.html并打印新增 / 跳过 / 失败三项计数。解析自测把一份目录页 HTML 存到本地可以直接验证解析器python web_sync.py--selftesthtml文件[base_url]base_url用于把页面里的相对链接还原成绝对地址缺省用内置示例站点。不带任何子命令运行时程序会尝试加载同目录下的web_gui.py并启动图形界面。十一、已知限制依赖服务端的目录列表页。站点用 JavaScript 动态渲染目录、或必须登录才能列出目录时无法使用 —— 工具只解析服务端返回的 HTML。时间筛选依赖页面上有修改时间。目录页不渲染时间列的站点mtime为空按时间筛选会全部不命中。托盘是 Windows 专有实现。win_tray.py直接调 Win32 APILinux / macOS 上会走「不可用」分支界面其余功能正常。目录探针会多发请求。对每个「像目录」的条目多发一次请求换取遍历完整在条目极多且几乎全是普通页面的站点上会略微增加耗时。写入端的风险自担。工具的定位是「把远端内容镜像到本地」不处理远端删除动作 —— 远端删掉的文件本地会保留。小结这个项目的技术含量集中在四处目录识别解决了「链接不带尾斜杠」导致的静默漏抓版本标识缓存 前缀抽样比对解决了断点续传把新旧版本拼在一起的经典事故纯 ctypes 托盘 poll() 队列绕开了在窗口过程里回调 Tcl 的崩溃打包前的 tkinter 断言 体积自检把一类「打包成功但双击没窗口」的静默失败拦在了构建阶段。这四处的共同点是它们对应的失败都不报错。工具类的程序里静默失败比崩溃更贵 —— 崩溃至少留下了现场静默失败留下的只是「好像少了点什么」。把这一类失败显式化是这套代码里最值得带走的部分。
返回列表