ARTICLE DETAIL

资讯详情

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

本地优先素材工具Day8:FastAPI+SQLite的数据导入加固与日报视图实践

本地优先素材工具Day8:FastAPI+SQLite的数据导入加固与日报视图实践 晚上九点整我合上电脑Day8的这个7小时开发时间块14:00-21:00正式收工了。最近在做一个本地优先的素材管理工具连续开发到了第八天今天下午两点开工一直到晚上九点才停下来。这七个半小时中间休息了两趟干的事情很集中把导入模块的健壮性补齐顺手把第一版日报视图做出来了。这段时间最大的感受是个人项目做到第八天其实已经过了最兴奋的搭建期开始进入缝合怪阶段——各个模块单独跑都没问题一旦要把它们串起来各种边角问题就冒出来了。如果你也在做类似的独立开发项目或者想用固定时间块推进副业这篇Day8的工作复盘应该能给你一些参考。我会尽量把当天的任务拆解、踩坑过程和修复思路都写清楚尤其是那几个让我多花了快两个小时的隐蔽问题。1. 14:00开场前的十分钟为什么这一天把时间块选在午后到夜晚1.1 Day8在整个开发周期里的位置前七天攒下来的进度条先交代一下项目背景方便后面说清楚Day8要干什么。这个素材管理工具的定位很朴素我平时会在各种地方留下碎片信息比如浏览器里的网页摘录、随手记的Markdown笔记、Excel里整理的资料清单以前它们散落在不同文件夹里查起来特别费劲。所以我想做一个本地优先的聚合工具把这些杂七杂八的内容统一导入进SQLite数据库再按日期、来源、标签做归类和回顾。前七天的进度大概是这样Day1到Day3搭骨架用FastAPI写了后端服务设计了SQLite的几张核心表素材表、来源表、标签表确定了最基本的数据模型。Day4到Day5做了网页剪藏的辅助脚本以及Markdown文件的批量导入功能这两块是当时最急用的入口。Day6到Day7加上了关键词搜索和标签分类接口界面还是纯JSON输出能用但谈不上好看。到Day7收工的时候功能链路已经通了素材可以从几个入口进库搜索接口也能跑。但是导入这块有几根刺没拔干净——比如CSV里某些日期格式会导致解析失败同一篇内容重复导入会生成重复记录。再加上一直没有可视化视图每次查东西都要拼curl命令体验很原始。所以Day8的计划就是把导入模块加固做出第一版按天的日报视图。1.2 直接开工前要做的事状态恢复三件套很多人连续开发项目容易在重新进入状态上浪费大量时间我前几天的教训就是下午一坐下就闷头写经常要花半小时才想起来上次做到哪了。所以今天14:00坐下之后我强制自己先花十分钟做三件事第一看一眼git status和git log确认当前工作区的状态明确上次提交的位置。第二打开TODO清单把Day8要做的任务圈出来只圈两个导入模块的健壮性补齐、日报视图第一版。第三把昨天写在草稿箱里的一句话接力棒读一遍——我在Day7收工时特意写了下一步应该从哪个函数开始改。这三件事做完之后心里就很踏实了。尤其是第三件昨天的接力棒写的是normalize_date()函数目前只有一种格式的解析路径需要扩展今天下午的工作就从这个函数开始。2. 下午的第一大块导入模块的健壮性补齐14:00-16:302.1 三种格式导入为什么要抽象成同一条管道下午两点到四点半是我今天状态最好的时段用来处理最需要动脑的导入模块。这个模块要支持三种入料格式Markdown文件、CSV表格、JSON数据。之前这三条导入路径是各写各的代码重复率高而且解析逻辑一旦有调整就要同步改三处容易漏。所以下午的第一个决定就是把三种格式的导入统一成一条管道。这个思路其实很简单每种格式只负责做一件事——把自己那种文件解析成统一的中间结构一个字典列表。中间结构经过一个Normalizer做字段规整、时间格式化、标签拆分。最后统一交给Writer层去写数据库Writer里面做幂等控制保证同一份内容重复导入了也不会产生重复记录。这样设计的好处是以后再加第四种格式比如TXT或者HTML只需要新写一个Parser不需要动后面的逻辑。坏处是前期要多写一层抽象如果项目很小可能不划算。但我的情况是素材格式还在不断增长这个抽象值得做。2.2 代码结构Parser Normalizer Writer 三层设计具体实现上我先把顶层接口定义清楚。三个Parser都继承同一个基类基类里规定好parse()方法的输入输出# parsers.py from pathlib import Path from typing import List, Dict, Any class BaseParser: def __init__(self, file_path: Path): self.file_path file_path def parse(self) - List[Dict[str, Any]]: raise NotImplementedError class MarkdownParser(BaseParser): def parse(self) - List[Dict[str, Any]]: # 读取每个md文件的frontmatter和正文 # 返回 [{title: ..., content: ..., source: ..., created_at: ...}] pass class CSVParser(BaseParser): def parse(self) - List[Dict[str, Any]]: # csv.DictReader逐行读取处理表头映射 pass class JSONParser(BaseParser): def parse(self) - List[Dict[str, Any]]: # json.load之后做结构映射 passNormalizer的逻辑才是今天的重头戏。它要把三个Parser产出的、字段名不完全一致的字典统一成数据库表的字段。比如MarkdownParser会返回created_at而CSVParser可能返回dateJSONParser可能返回timestamp。Normalizer内部维护一个字段映射表把所有别名都映射成created_at。Writer层今天做得最扎实的部分是幂等控制。我不希望同一个Markdown文件被导入两次就出现两条一模一样的素材。这个问题的标准解法是用唯一约束对每一条素材计算一个内容指纹在素材表里加一列content_hash写入时用ON CONFLICT做跳过。指纹的算法很简单用hashlib对标题 正文全文 来源做sha256取十六进制前32位。# writer.py import hashlib import sqlite3 def build_content_hash(title: str, content: str, source: str) - str: raw f{title}|{content}|{source}.strip() return hashlib.sha256(raw.encode(utf-8)).hexdigest()[:32] def insert_material(conn: sqlite3.Connection, material: dict) - bool: content_hash build_content_hash( material[title], material[content], material[source] ) cursor conn.execute( INSERT OR IGNORE INTO materials (title, content, source, created_at, content_hash) VALUES (?, ?, ?, ?, ?) , ( material[title], material[content], material[source], material[created_at], content_hash, ), ) return cursor.rowcount 0INSERT OR IGNORE这个写法很省事重复数据进来直接被忽略不会报错也不会中断批量导入流程。但这里埋了一个隐患我当时没太在意结果傍晚就翻车了——这个我后面专门讲。2.3 写代码时留下的一个隐患后来在傍晚暴露了下午写Normalizer的时候我图省事写了一个看起来没问题的日期处理分支。当时心里想的是日期格式嘛不外乎ISO格式那种2025-06-01或者带时间戳的2025-06-01 14:30:00最多再兼容一下2025/06/01。我为这三种情况写了正则匹配其余的扔给dateutil.parser.parse去兜底。写完之后我看了一下逻辑觉得挺完善就往下推进了。真正的问题出在CSV导入路径——Excel导出CSV的时候日期列常常是2025/6/1这种短格式更恶心的是如果Excel里那个单元格本来就是个文本导出来可能还带着看不见的空格或者换行符。这些情况我的正则统统没有覆盖到dateutil也不是万能碰上2025/6/1倒是能解析但如果值变成了2025/6/1 末尾带空格就会直接解析失败。我当时偷懒了没有在Normalizer里加一个兜底清理的步骤比如对所有日期字符串先做strip操作。这个坑下午埋下傍晚准时爆了。四点半的时候我还以为导入模块已经加固完成美滋滋地把一批测试CSV跑了一遍结果报了二十多条警告全指向同一个函数。先记下这个问题继续往后收尾完再说。3. 傍晚时分的调试风暴数据去重和时区这两个烂摊子16:30-18:303.1 第一个意外Excel导出的CSV日期格式傍晚六点左右我去跑当天的集成测试导入一个包含了100条记录的CSV文件这个文件是我从以前的一个表格软件里导出来的里面的日期格式五花八门。测试脚本直接报出了24条警告全部来自导入模块警告内容是date parsing failed, fallback to None。我第一反应是有点懵上午明明手动测过几种格式都通过了。于是我把那24条记录打出来看发现它们都有一个共同特征日期字段的值长得跟预期不太一样比如2025/6/1 —— 这是短斜线格式我以为正则里兼容了其实没有。2025/6/1 —— 多了尾部空格。2025-6-1 —— 月份和日期没有补零。这三个变体其实都是同一种数据在Excel里的不同显示方式。我的正则写得太严只匹配2025-06-01这唯一的形态遇到其他形态就直接panic不会温和地尝试第二种解析方式。这个问题排查起来不算难但很启发我真实世界的脏数据永远比你在代码里想的格式要多一种。修复方案是写一个多级fallback链一级不行就下一级全都失败才兜底为None# normalizer.py from datetime import datetime def normalize_date(raw_value) - str | None: if raw_value is None: return None text str(raw_value).strip() if not text: return None candidates [ %Y-%m-%d %H:%M:%S, %Y-%m-%dT%H:%M:%S, %Y/%m/%d, %Y-%m-%d, %Y/%m/%d %H:%M:%S, %Y-%m-%d %H:%M, ] for fmt in candidates: try: return datetime.strptime(text, fmt).isoformat(sep ) except ValueError: continue return None这个函数不算优雅但极其实用。所有导入进来的日期值都先经过它能解析就统一成2025-06-01 14:30:00这个标准格式解析不了就返回None绝不中断导入流程。宁可留一个空日期也不让一条素材因为格式问题丢掉。3.2 第二个意外同一篇笔记被导入三次生成了三份晚上七点日期问题刚修完我顺手想验证一下日报视图。结果在数据库里一查发现有几篇笔记的标题在列表里出现了三次内容一模一样明显是重复导入造成的。我当时有点上火因为下午做幂等控制的时候我明明已经加了content_hash和INSERT OR IGNORE怎么还会重复排查链路比较曲折我一步步来第一步检查content_hash是否真的存入数据库了。我先把数据库里那三条重复记录的hash值打出来一看好了问题是这样的——hash值确实存在但三条记录的hash完全不一样。第二步对比它们的hash从哪里来。我把三条记录的title、content、source分别打印出来发现title和source完全一致唯独content在数据库里看起来一致但hash结果不同。第三步深挖content差异。我本地重新计算了一遍发现其中一条内容的正文末尾多了一个换行符另一条正文开头多了一个空格。也就是说这三个文件在磁盘上的字节并不完全相同虽然人眼看不出差别但sha256算出来天差地别。这就导致三条记录被视为不同内容INSERT OR IGNORE当然拦不住。第四步修content_hash的算法。把原始内容做归一化后再计算归一化规则很简单把所有空白符空格、换行、制表符压缩成单个空格再strip掉首尾空白。这样字节级别的细微差异就不会再导致误判。# writer.py import re def normalize_content_for_hash(text: str) - str: return re.sub(r\s, , text.strip()) def build_content_hash(title: str, content: str, source: str) - str: normalized |.join([ normalize_content_for_hash(title), normalize_content_for_hash(content), normalize_content_for_hash(source), ]) return hashlib.sha256(normalized.encode(utf-8)).hexdigest()[:32]这一步虽然简单但之前漏掉它的原因很典型我在写hash算法的时候只想着用hash来去重却忘了hash的输入必须先规整。其实去重真正要识别的是内容语义上相同而不是字节完全相同。这套思路在后来想加相似内容合并功能的时候也很有用比如轻微改动过的内容也可以计算相似度再决定是否归并。3.3 时区这个烂摊子以及排查链路的整体复盘被第一天加重的还有时区问题。我在导入Markdown文件时文件名往往带着日期比如2025-06-01-读书笔记.md。之前偷懒直接用了文件名里的日期当作created_at没考虑时区转换。到了做日报视图的时候问题暴露了我用本地时区记录的时候是晚上11点但数据库存的是UTC还是本地时间完全取决于当时Python进程的时区配置极不稳定。更麻烦的是过去几天的导入操作有的是在命令行跑的脚本有的是通过FastAPI接口调的两边的时区配置还不一样。这就导致数据库里一部分created_at是UTC一部分是本地时间日报汇总的时候就会出现素材跑到了前一天或后一天的诡异现象。排查这个问题的过程我不想细说了总之最后我做了几件事第一数据库层面统一约定所有时间字段一律存储为UTC ISO字符串不带时区后缀。第二写入前统一用datetime.now(timezone.utc)。读取后再由前端显示时转换成浏览器本地时间。第三历史数据跑了一个迁移脚本把所有存量记录的时间统一转成UTC。这一步做完日报视图的日期分组就再也不漂了。其实这个坑在Day3建表的时候就该定的当时嫌麻烦没定后来足足多花了将近一个小时来补救。这算是我Day8最大的经验教训之一。4. 晚间场的产品化收敛第一版日报视图怎么做到又简单又能用19:00-21:004.1 日报需求拆解不要一张炫酷大屏要一张够用的表时区和去重问题处理完之后已经是晚上七点了。按照计划晚上要交掉日报视图的第一版。这个视图不需要多炫酷我给自己定了三个能力范围按日期筛选输入某一天能看到当天收集的所有素材。按来源分组同一天里网页剪藏、Markdown笔记、CSV导入的来源信息要能区分。可跳转每一条素材最好能带上原始链接不然查了来源也没法回溯。三个能力里面最难的是按来源分组因为我的素材表里source字段是自由文本有人填的是web有人填的是网页剪藏还有人直接填了网址。为了做分组我得先来源归一化——把web、网页剪藏、http...这类值统一映射成网页类目把md、markdown、笔记映射成笔记类目。这个映射表写死也好后面要改再抽出来。4.2 FastAPI接口和原生JS渲染不引入框架后端部分很简单加一个接口就行。它的逻辑是接收日期参数从SQLite里查当天素材按来源分组后返回JSON# app.py from fastapi import FastAPI, Query import sqlite3 app FastAPI() app.get(/api/daily) def get_daily(date: str Query(..., regexr^\d{4}-\d{2}-\d{2}$)): conn sqlite3.connect(materials.db) conn.row_factory sqlite3.Row rows conn.execute( SELECT title, content, source, url, created_at FROM materials WHERE date(created_at) date(?) ORDER BY source, created_at , (date,), ).fetchall() conn.close() groups {} for row in rows: group_key normalize_source_category(row[source]) groups.setdefault(group_key, []).append(dict(row)) return groups前端直接用原生JavaScript配合fetch实现。一开始我也犹豫过要不要上Vue或者React但为了一页日报引入一个几百KB的框架实在太重了。原生JS做数据渲染完全够用// daily.js async function loadDailyReport() { const dateInput document.getElementById(date-input); const container document.getElementById(report-container); container.innerHTML p classloading数据加载中.../p; try { const resp await fetch(/api/daily?date${dateInput.value}); if (!resp.ok) { throw new Error(HTTP ${resp.status}); } const groups await resp.json(); renderGroups(groups); } catch (err) { container.innerHTML p classerror加载失败${err.message}/p; } }renderGroups的逻辑就是把分好组的对象遍历一遍拼成HTML字符串再插入容器。用一个简单的模板字符串就能搞定特殊字符注意做转义防止XSS。这块我花的时间不多真正花心思的是下面那三个容易被忽略的交互细节。4.3 空状态、加载态、错误提示三个界面细节别偷懒我做前端界面最容易翻车的地方就是只考虑顺利情况数据一空或者请求一挂就白屏。这三个细节今天都做了处理空状态查询某一天没有任何素材时页面要明确显示这一天没有记录并且最好给出一个方便的跳转入口去导素材。不处理的话用户会以为自己查错日期了体验很糟糕。加载态接口虽然快但网络环境不好时也会等这时候页面显示数据加载中...是必要的。我今天甚至为了模拟慢接口临时在FastAPI里加了time.sleep(1)专门观察加载态显示是否正常。错误提示后端如果500了前端不能静默失败必须把错误信息显示出来。我见过很多内部工具一报错就白屏没人知道发生了什么。这三个细节每个只需几行代码但对工具的信任感影响巨大。内部工具虽然使用者是自己或者一两个朋友但如果是看起来坏了而不是明确告诉你坏了这个工具很快就会没人用。日报视图做到晚上八点半基本成型。我查了今天的导入数据用页面看了一遍6月3号那天的素材分组正常跳转链接也能用。又补了日期选择器的默认值——默认展示今天的日期——然后做了最后一轮手测。5. 21:00收工后的十分钟复盘今天学到的三件事和留给Day9的接力棒5.1 七小时得出的三条时间管理经验晚上九点我按惯例做了个简短复盘不为别的就是为了给明天的自己留下线索。今天这7小时高强度工作下来有几条体会想写在这里经验一连续开发不要憋大功能拆小步才走得动。我今天原计划是两个功能块实际做下来发现每个功能块背后都藏着一堆小坑。如果一开始把目标定成今天搞定导入日报还有搜索优化晚上肯定又收不了工而且会焦虑。拆成两个主任务之后每一个完成都有正反馈心里会舒服很多。经验二Bug往往不在复杂的部分而在最不起眼的格式转换。今天上午引以为傲的抽象设计没有翻车翻车的地方全是字符串格式、时区、空白符这种基础得不能再基础的东西。对付这些脏数据的思路只有一个防御式编程所有外部输入都假设是脏的先清洗再使用。经验三收工前一定要留一个明确的小任务给第二天。这个接力棒我前七天一直在用Day7留在草稿箱里的那句话帮我省下了今天至少二十分钟的状态恢复时间。所以今天收工前我也写了明天要做的第一件事。5.2 留给Day9的四个待办和一条迁移脚本根据今天的工作进展Day9的开工清单大概是这样的撰稿把今天深夜写的时区迁移脚本跑完并在日报视图里验证分组是否完全正确。补一个素材详情页目前点击素材标题还是只能看到列表摘要没有完整的正文展示。标签管理现在标签字段还是一个简单的逗号分隔文本要拆成标签表和素材标签关联表。做一次完整的数据备份策略SQLite文件放在本地我打算加一个一键导出JSON的功能以防哪天文件损坏。这四个待办里面第一个是明天必须做完的其余三个可以看状态推进。每个都够小不至于把明天的压力一次性拉满。5.3 给同样在做个人项目的朋友几句实话Day8的7小时下来最大的收获可能反而不是代码而是对个人项目持续节奏有了更深的理解。固定时间块这件事我以前试过很多次都没坚持下来这几天做这个项目反而慢慢找到了感觉每天下午到晚上的固定时段不追求时长的绝对多但一旦坐下来就确保输入和输出是有闭环的。另外一个感受是个人项目最容易崩塌的节点是Day5到Day10之间。因为新鲜感过去了代码开始变脏之前没处理的历史包袱会一个个跳出来。这时候如果时间块安排得不合理很容易打击积极性然后项目就烂尾了。但换个角度看这个阶段恰恰是项目变得可靠的关键——所有真实使用中会遇到的问题都会在这个阶段集中暴露。今天是Day8我处理完这一波脏数据和重复问题之后反而觉得这个工具离能真正长期使用又近了一步。明天下午两点开工我已经知道第一件事是什么了。这个感觉比多写两小时代码要踏实得多。
返回列表