普贤教程 › 生态包
数据访问与解析实战
SQL 构造、ORM 映射、迁移计划、DOM / XPath / 流式 XML、网页抓取、EXIF、路径运算、日期区间与相对时间。这一组的共同点是只产出数据,不碰 IO:SQL 是字符串,迁移是计划,XML 是树,路径运算是纯函数——所以能离线测、能断言。
一个脚本跑完 10 个包
px
import querybuilder import orm import migration import domlite import xmlstream import xpath import scraper import fileinfo import dateperiod import strtotime def main(): var q = qb_select("users") q = qb_where(q, "age", ">=", 18) q = qb_where_in(q, "city", ["北京", "上海"]) q = qb_order_by(q, "id", "desc") q = qb_limit(q, 20) print(qb_build(q)) print(qb_build(qb_insert("users", {"name": "甲", "age": 20}))) var m = orm_new("users", "id") m = orm_field(m, "name", "string") m = orm_field(m, "age", "integer") print(json_stringify(orm_to_row(m, {"id": 1, "name": "甲", "age": 20}))) print(json_stringify(orm_from_row(m, {"id": "1", "name": "甲", "age": "20"}))) print(json_stringify(orm_validate(m, {"name": "", "age": 20}))) var mig = mig_new() mig_add(mig, 1, "create_users", "CREATE TABLE users(id INT)", "DROP TABLE users") mig_add(mig, 2, "add_email", "ALTER TABLE users ADD email TEXT", "ALTER TABLE users DROP email") print(str(mig_count(mig)) + " 条,待执行 " + json_stringify(mig_plan_up(mig, [1]))) print("回滚到 1 " + json_stringify(mig_plan_rollback(mig, 1, [1, 2]))) var ul = dom_new("ul", {"class": "list"}, []) dom_append(ul, dom_new("li", {}, [dom_text("甲")])) dom_append(ul, dom_new("li", {}, [dom_text("乙")])) print(dom_to_xml(ul, "")) print("li=" + str(xp_count(ul, "li")) + " 首项=" + xp_text(ul, "li")) print(str(xp_exists(ul, "ol")) + " " + str(xp_attr(ul, "ul", "class"))) var w = xw_new() xw_start_document(w) xw_start_element(w, "root", {}) xw_write_element(w, "name", "甲") xw_end_element(w, "root") xw_end_document(w) var xml = xw_get_output(w) print(xml) var r = xr_new("<a><b>1</b><c/></a>") var n = 0 var ev = xr_read_next(r) while ev != null: n += 1 ev = xr_read_next(r) print("事件 " + str(n)) var html = "<html><head><title>示例页</title></head><body><a href=\"/a\">甲</a><a href=\"https://b.example\">乙</a><img src=\"/x.png\"></body></html>" print(json_stringify(sc_extract_links(html))) print(sc_extract_title(html) + " / " + json_stringify(sc_extract_images(html))) var fi = fi_new("/var/log/app/error.log") print(fi_get_filename(fi) + " " + fi_get_basename(fi) + " " + fi_get_extension(fi) + " " + fi_get_dirname(fi)) print(fi_normalize(fi_join("/srv/app", "../etc/passwd"))) var dp = dp_new(1700000000, 1700086400, 86400) print(str(dp_count(dp)) + " 个时间点 " + json_stringify(dp_format(dp, "%Y-%m-%d")) + " 含中期=" + str(dp_contains(dp, 1700043200))) var base = 1700000000 print(str(st_parse_relative("tomorrow", base)) + " " + str(st_parse_relative("yesterday", base)) + " " + str(st_parse_relative("next monday", base))) print("+1 day = " + str(st_parse_relative("+1 day", base)) + ",2 days ago = " + str(st_parse_relative("2 days ago", base)))
text
SELECT * FROM `users` WHERE `age` >= 18 AND `city` IN ('北京', '上海') ORDER BY `id` desc LIMIT 20 INSERT INTO `users` (`name`, `age`) VALUES ('甲', 20) {"name":"甲","age":20} {"name":"甲","age":20} ["name is empty"] 2 条,待执行 [{"version":2,"name":"add_email","sql":"ALTER TABLE users ADD email TEXT"}] 回滚到 1 [{"version":2,"sql":"ALTER TABLE users DROP email"}] <ul class="list"><li>甲</li><li>乙</li></ul> li=2 首项=甲 false list <?xml version="1.0" encoding="UTF-8"?> <root><name>甲</name></root> 事件 6 ["/a","https://b.example"] 示例页 / [{"src":"/x.png"}] error.log error log /var/log/app /srv/etc/passwd 2 个时间点 ["2023-11-14","2023-11-15"] 含中期=false 1700086400 1699913600 1700518400 +1 day = 1700086400,2 days ago = 1699827200
querybuilder:链式 SQL
px
var q = qb_select("users") q = qb_where(q, "age", ">=", 18) q = qb_where_in(q, "city", ["北京", "上海"]) q = qb_where_like(q, "name", "甲%") q = qb_order_by(q, "id", "desc") q = qb_limit(q, 20) qb_build(q) # 内联转义后的 SQL 字符串 qb_build_params(q) # [SQL(值是 ?), 参数列表] qb_build_ok(q) # Ok([sql, params]) / Err("表名不能为空")
qb_build 是内联转义,qb_build_params 才是参数化两个入口分工不同:qb_build 把值经 qb_escape 转义后直接拼进 SQL,转义只处理引号与反斜杠;接用户输入一律用 qb_build_params,产出 [sql, [7]] 这种形态,交给驱动的预处理绑定。要前置校验(空表名、空列)用 qb_build_ok,返回 Err 而不是拼出半条 SQL。dbal:参数化查询构造
dbal 的函数全部是 db_* 前缀,与 querybuilder 的 qb_* 分开,两个包可以在同一个文件里 import:
px
import dbal import querybuilder var q = db_new("users") q = db_select(q, ["id", "name"]) q = db_where(q, "age", ">", 18) q = db_limit(q, 10) print(db_to_sql(q)) # SELECT id, name FROM users WHERE age > ? LIMIT 10 print(db_params(q)) # [18] print(db_build(q)) # [sql, params] 一起取 print(db_insert_sql("users", {"name": "甲"})) # INSERT INTO users (name) VALUES (?)
两个包的函数前缀分开dbal 全部导出
db_*,querybuilder 导出 qb_*,同一文件里同时 import 不冲突。qb_select 是 querybuilder 的(只收表名),dbal 的是 db_select(qb, columns)。二选一的口径:要安全用 dbal(参数化,适合接真实的 MySQL 驱动);要方便用 querybuilder(链式、能拼复杂 join,取参数用 qb_build_params)。生产写库一律 dbal。
orm:字段映射与校验
px
var m = orm_new("users", "id") m = orm_field(m, "name", "string") m = orm_field(m, "age", "integer") orm_to_row(m, {"id":1,"name":"甲","age":20}) # {"name":"甲","age":20}(pk 不进 row) orm_from_row(m, {"id":"1","name":"甲","age":"20"}) # {"name":"甲","age":20}(integer 转成 int) orm_validate(m, {"name":"", "age":20}) # ["name is empty"]
只处理声明过的字段
orm_from_row 按 orm_field 逐个取出并按声明类型转换(声明 integer,从库里拿来 "20" 转成 20);没声明的字段(例子里的 id)不会出现在结果里,主键要自己另外取。orm_validate 查三类问题:必填缺失、空串、类型不符。migration:迁移计划
px
var mig = mig_new() mig_add(mig, 1, "create_users", up_sql, down_sql) mig_add(mig, 2, "add_email", up_sql, down_sql) mig_plan_up(mig, applied_versions) # 待执行的 [{version,name,sql}] mig_plan_rollback(mig, target, applied) # 要回滚的 mig_latest(mig) / mig_count(mig)
只生成计划,不执行 SQL、不记录版本。版本表要自己维护:跑完把 version 写进 schema_migrations,下次读出来传给 mig_plan_up。
domlite + xpath:建树与查询
px
var ul = dom_new("ul", {"class":"list"}, []) dom_append(ul, dom_new("li", {}, [dom_text("甲")])) dom_to_xml(ul, "") # <ul class="list"><li>甲</li></ul> dom_inner_text(ul) # 甲 dom_find_by_tag(ul, "li") # list xp_count(ul, "li") # 2 xp_text(ul, "li") # 甲(第一个) xp_select_all(ul, "li") # 节点列表 xp_select_by_attr(ul, "ul", "class", "list") xp_attr(ul, "ul", "class") # list
dom_to_xml 的 indent 只收字符串传 0 会在 indent + "<" 处抛 R1002 无法相加: int + string。要缩进传 " ",不要缩进传 ""。
另外 xpath 不是真 XPath:只支持按标签名的直接子节点查询,没有路径表达式、谓词、轴。//div[@id="x"] 这种写法它不认。xmlstream:流式读写
px
var w = xw_new() xw_start_document(w) xw_start_element(w, "root", {}) xw_write_element(w, "name", "甲") xw_end_element(w, "root") xw_end_document(w) xw_get_output(w) var r = xr_new(xml) var ev = xr_read_next(r) # {type:"start"/"end"/"pi"/"comment", name, attrs} while ev != null: … ev = xr_read_next(r)
读循环看
xr_is_done,或直接取全量xr_read_next 读完返回 null 并推进位置,所以 while not xr_is_done(r) 与 while ev != null 两种写法都对。要一次拿完用 xr_read_all(r) 返回事件列表;重读同一份 XML 前先 xr_reset(r)。写侧 xw_write_element 会自动转义 & / < / >,不要把已转义的文本再喂进去。scraper:抓页面里的链接与文本
px
sc_extract_links(html) # ["/a", "https://b.example"] sc_extract_images(html) # [{"src":"/x.png"}] sc_extract_title(html) # 示例页 sc_extract_meta(html, "description") sc_extract_text(html) # 去标签后的正文 sc_strip_tags(html)
基于字符串匹配,不是 HTML 解析器:不处理实体、不补全错误嵌套、不管 <base>。相对链接不会自动补全,要自己拼上 https://host。对「拿一张静态页的几个字段」够用;复杂的页面换 htmlparse 包。
exif:JPEG 元数据
px
exif_has_marker(jpeg_bytes) var seg = exif_find_segment(jpeg_bytes, 0xE1) # APP1 exif_parse_tiff(jpeg_bytes, tiff_offset) # {tag: value} exif_tag_name(0x0112) # Orientation
只解析 EXIF 的 TIFF 结构(IFD0 / ExifIFD / GPS 的基本标签),不做缩略图、不解析 MakerNote。要拿拍摄时间与 GPS 够用;要做相机型号专有字段得自己按 tag 读原始值。
入参是
bytes,不是路径先 read_file_opt(path).ok() 读成字节再传。大图只读前 64KB 也能解出 IFD(EXIF 段在文件头附近),不必整文件加载。fileinfo:纯路径运算
px
var fi = fi_new("/var/log/app/error.log") fi_get_filename(fi) # error.log fi_get_basename(fi) # error fi_get_extension(fi) # log fi_get_dirname(fi) # /var/log/app fi_is_absolute(fi) # true fi_join("/srv/app", "../etc/passwd") # /srv/app/../etc/passwd fi_normalize("/srv/app/../etc/passwd") # /srv/etc/passwd
fi_normalize 会真的把 .. 折上去上面这条从 /srv/app 一路折到了 /srv/etc/passwd。如果拿用户输入拼路径,必须先 normalize 再校验前缀,否则 ../ 直接穿越出沙箱。fi_join 本身不做任何限制。dateperiod:日期区间
px
var dp = dp_new(1700000000, 1700086400, 86400) dp_count(dp) # 2(含端点) dp_to_list(dp) # [1700000000, 1700086400] dp_contains(dp, ts) # 是否落在步长上 dp_format(dp, "%Y-%m-%d") # ["2023-11-14","2023-11-15"] ← 全区间 dp_format_one(dp, "%Y-%m-%d") # "2023-11-14" ← 只取第一个 dp_format_at(dp, "%Y-%m-%d", 1) # "2023-11-15" dp_days(s, e) / dp_hours(s, e) / dp_weeks(s, e) / dp_months(s, e) dp_month_starts(s, 3) # 之后 3 个日历月的起点
dp_format 返回整个列表,单值用 dp_format_onedp_format(dp, fmt) 返回区间内所有时间点的格式化结果;只要一个值用 dp_format_one,要第 n 个用 dp_format_at。interval_sec 必须为正,dp_new(s, e, 0) 这种会直接判无效(dp_valid → false),不会挂住。另外 dp_months 是 30 天近似,要日历月用 dp_month_starts(31 号的月会在短月落到月末)。strtotime:英文相对时间
px
st_parse_relative("tomorrow", base) # base + 86400 st_parse_relative("yesterday", base) # base - 86400 st_parse_relative("next monday", base) # 下一个周一 st_parse_relative("+1 day", base) # base + 86400 st_parse_relative("2 days ago", base) # base - 172800 st_parse_relative_ok("garbage", base) # Err(无法识别的写法: garbage) st_parse("now") # 以当前时间为基准
认不出的写法用
st_parse_relative_ok 显式报错st_parse_relative 遇到不认识的词会原样返回基准时间(和「本来就没变」分不清);需要区分时用 _ok 版本,拿 Err 里的诊断文案。数字与单位之间空格可有可无(+1 day / +1day / in 2 hours 都认)。