first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
-- rond-with-you 数据一致性审计(PostgreSQL)
-- 用法:psql "<dsn>" -f tools/audit_db.sql
-- 各项期望值均为 0;非 0 说明有漂移或悬空引用,逐项排查。
-- 注意:Windows + Git Bash 下 psql 不能直接吃中文命令行参数(会被按 GBK 传出去),
-- 所以这个文件必须保存为 UTF-8 并用 -f 执行。
\pset border 1
\echo '=== 1. 聚合统计漂移(应为 0) ==='
SELECT 'place.visit_count drift' AS chk, count(*) AS n
FROM places p
WHERE COALESCE(p.visit_count,0) <> (SELECT count(*) FROM visits v WHERE v.place_id=p.id)
UNION ALL
SELECT 'datasets.visit_count drift', count(*)
FROM datasets d
WHERE COALESCE(d.visit_count,0) <> (SELECT count(*) FROM visits v WHERE v.dataset_id=d.id)
UNION ALL
SELECT 'datasets.place_count drift', count(*)
FROM datasets d
WHERE COALESCE(d.place_count,0) <> (SELECT count(*) FROM places p WHERE p.dataset_id=d.id)
UNION ALL
SELECT 'duration_min wrong', count(*)
FROM visits WHERE departure IS NOT NULL
AND COALESCE(duration_min,-1) <> GREATEST(0, floor(extract(epoch FROM (departure-arrival))/60)::int)
UNION ALL
SELECT 'departure < arrival', count(*)
FROM visits WHERE departure IS NOT NULL AND departure < arrival;
\echo '=== 2. 悬空引用(应为 0) ==='
SELECT 'visits -> other dataset place' AS chk, count(*)
FROM visits v JOIN places p ON p.id=v.place_id WHERE p.dataset_id<>v.dataset_id
UNION ALL
SELECT 'movements.from_place', count(*)
FROM movements m WHERE m.from_place_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM places p WHERE p.id=m.from_place_id)
UNION ALL
SELECT 'movements.to_place', count(*)
FROM movements m WHERE m.to_place_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM places p WHERE p.id=m.to_place_id)
UNION ALL
SELECT 'movements.from_visit_src', count(*)
FROM movements m WHERE m.from_visit_src IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.from_visit_src)
UNION ALL
SELECT 'movements.to_visit_src', count(*)
FROM movements m WHERE m.to_visit_src IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.to_visit_src)
UNION ALL
SELECT 'weather.visit_src', count(*)
FROM weather w WHERE w.visit_src IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src)
UNION ALL
SELECT 'visit_tags dangling', count(*)
FROM visit_tags vt
WHERE NOT EXISTS (SELECT 1 FROM visits v WHERE v.id=vt.visit_id)
OR NOT EXISTS (SELECT 1 FROM tags t WHERE t.id=vt.tag_id)
UNION ALL
SELECT 'visit_tags cross-dataset', count(*)
FROM visit_tags vt
JOIN visits v ON v.id=vt.visit_id JOIN tags t ON t.id=vt.tag_id
WHERE v.dataset_id<>t.dataset_id
UNION ALL
SELECT 'movements end<start', count(*)
FROM movements WHERE started_at IS NOT NULL AND ended_at IS NOT NULL AND ended_at < started_at;
\echo '=== 3. 留档与现役表的差集(skipped 行属正常,见下方明细) ==='
SELECT r.entity, count(*) FILTER (WHERE r.skipped) AS skipped_ok, count(*) FILTER (WHERE NOT r.skipped) AS should_be_zero
FROM entity_raw r
WHERE (r.entity='ZLOCATION' AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=r.dataset_id AND p.src_pk=r.src_pk))
OR (r.entity='ZVISIT' AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk))
OR (r.entity='ZMOVEMENT' AND NOT EXISTS (SELECT 1 FROM movements m WHERE m.dataset_id=r.dataset_id AND m.src_pk=r.src_pk))
OR (r.entity='ZHOURLYWEATHER' AND NOT EXISTS (SELECT 1 FROM weather w WHERE w.dataset_id=r.dataset_id AND w.src_pk=r.src_pk))
OR (r.entity='ZACTIVITY' AND NOT EXISTS (SELECT 1 FROM activities a WHERE a.dataset_id=r.dataset_id AND a.src_pk=r.src_pk))
OR (r.entity='ZTAG' AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.dataset_id=r.dataset_id AND t.src_pk=r.src_pk))
GROUP BY r.entity;
\echo '=== 4. 笔记本/投票按 src_pk 关联,是否落在当前数据集(>0 即跨数据集错位) ==='
SELECT 'notes not in active dataset' AS chk, count(*) FROM place_notes n
WHERE NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=(SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1) AND p.src_pk=n.src_pk)
UNION ALL
SELECT 'votes not in active dataset', count(*)
FROM place_votes v
WHERE NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=(SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1) AND p.src_pk=v.src_pk);
\echo '=== 5. 数据特征(非缺陷,仅供了解) ==='
SELECT 'places 同名同坐标重复组数' AS item, count(*)::text AS v FROM (
SELECT 1 FROM places GROUP BY name, round(lat::numeric,5), round(lon::numeric,5) HAVING count(*)>1) t
UNION ALL
SELECT 'visits 零时长', count(*)::text FROM visits WHERE duration_min=0
UNION ALL
SELECT 'visits 超 30 天', count(*)::text FROM visits WHERE duration_min>43200
UNION ALL
SELECT 'visits 无活动', count(*)::text FROM visits WHERE activity_id IS NULL
UNION ALL
SELECT 'places 无类别', count(*)::text FROM places WHERE COALESCE(poi_category,'')=''
UNION ALL
SELECT 'places 无坐标', count(*)::text FROM places WHERE COALESCE(lat,0)=0 AND COALESCE(lon,0)=0;
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""照片导入的坐标体检与批量换算。
为什么需要它:导入页把「用户选的坐标系」交给 /api/regeo(coord 字段),服务端只对 wgs84
做换算,坐标再 toFixed(6) 回填表单落库。选错一次,显示坐标就一字不差等于 EXIF 原值,在
GCJ-02 底图上偏 350~680m。库里没有 created_at,也没记录导入时的坐标系选择,所以判据只有
原图 EXIF:
库内坐标 == round(EXIF, 6) -> 没转换,需要修
库内坐标 == gcj(round(EXIF, 6)) -> 已转换,正确
(实测 24/24 命中,0 反例)
没有原图时退化为「sp > rond 基线上限 = 站点新建」,只能靠 --include-unverified 才动。
用法:
python tools/photo_coord_audit.py --site https://life.shiroko.one --cookie "uid|tok" \
--photos D:/photos/libo --photos D:/photos/foshan --out fix.csv
# 确认清单后加 --apply
"""
import argparse
import csv
import html as htmllib
import io
import json
import math
import os
import re
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
# 生产机只监听 IPv4:默认解析出 IPv6 会先撞在超时上,而 urllib 不会自动回退
_gai = socket.getaddrinfo
def _gai_prefer_v4(host, port, family=0, type=0, proto=0, flags=0):
infos = _gai(host, port, family, type, proto, flags)
v4 = [i for i in infos if i[0] == socket.AF_INET]
return v4 or infos
socket.getaddrinfo = _gai_prefer_v4
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) rond-coord-audit"
FORM_RE = re.compile(r'<form method="post" action="/admin/edit/place/update".*?</form>', re.S)
ORIG_RE = re.compile(r'导入时的坐标:<span class="mono">([-0-9.]+),\s*([-0-9.]+)</span>')
class _NoRedirect(urllib.request.HTTPRedirectHandler):
# 保存成功是 303 + Location 里带 ok=/err=,跟过去就看不到结论了;
# 会话失效也是 303 跳登录页,跟着走会静默拿到登录页 HTML 当成空数据
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_opener = urllib.request.build_opener(_NoRedirect)
def http(url, cookie="", data=None, timeout=60, tries=3):
# 走 Cloudflare 时长页面偶发读超时,重试比直接失败划算;写库的重试由调用方控
last = None
for i in range(tries):
req = urllib.request.Request(url, data=data)
req.add_header("User-Agent", UA)
if cookie:
req.add_header("Cookie", "rond_session=" + cookie)
if data is not None:
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with _opener.open(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e: # 超时 / 连接被掐
last = e
if i + 1 < tries:
time.sleep(1.5 * (i + 1))
raise RuntimeError("请求失败 %s:%s" % (url, last))
def field(block, name):
m = re.search(r'name="%s"[^>]*?value="([^"]*)"' % re.escape(name), block)
return htmllib.unescape(m.group(1)) if m else ""
def parse_places(page_html):
out = []
for block in FORM_RE.findall(page_html):
src = field(block, "src_pk")
if not src.isdigit():
continue
cat = ""
sel = re.search(r'<select name="category".*?</select>', block, re.S)
if sel:
cur = re.search(r'<option value="([^"]*)" selected>', sel.group(0))
cat = htmllib.unescape(cur.group(1)) if cur else ""
p = {
"src_pk": int(src),
"name": field(block, "name"),
"category": cat,
"lat": field(block, "lat"),
"lon": field(block, "lon"),
"province": field(block, "province"),
"city": field(block, "city"),
"district": field(block, "district"),
}
mo = ORIG_RE.search(block)
p["orig"] = (mo.group(1) + "," + mo.group(2)) if mo else ""
out.append(p)
return out
def fetch_places(site, cookie, verbose=True):
"""翻页读完整个地点列表(含被区域黑名单/迷雾遮罩挡住、公开接口看不到的点)。"""
places, seen, page = [], set(), 1
while page <= 200:
status, _, body = http("%s/admin/edit?page=%d" % (site, page), cookie)
if status != 200:
raise SystemExit("读 /admin/edit?page=%d 失败:HTTP %d(会话过期?)" % (page, status))
chunk = parse_places(body.decode("utf-8", "replace"))
chunk = [p for p in chunk if p["src_pk"] not in seen]
if not chunk:
break
for p in chunk:
seen.add(p["src_pk"])
places.extend(chunk)
if verbose:
sys.stderr.write("\r读取地点列表… 第 %d 页,累计 %d 个" % (page, len(places)))
sys.stderr.flush()
page += 1
if verbose:
sys.stderr.write("\n")
return places
def wgs2gcj(lat, lon):
a, ee = 6378245.0, 0.00669342162296594323
def tl(x, y):
r = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
r += (20.0 * math.sin(6 * x * math.pi) + 20.0 * math.sin(2 * x * math.pi)) * 2 / 3
r += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3 * math.pi)) * 2 / 3
r += (160.0 * math.sin(y / 12 * math.pi) + 320 * math.sin(y * math.pi / 30)) * 2 / 3
return r
def tg(x, y):
r = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
r += (20.0 * math.sin(6 * x * math.pi) + 20.0 * math.sin(2 * x * math.pi)) * 2 / 3
r += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3 * math.pi)) * 2 / 3
r += (150.0 * math.sin(x / 12 * math.pi) + 300.0 * math.sin(x / 30 * math.pi)) * 2 / 3
return r
dl, dn = tl(lon - 105.0, lat - 35.0), tg(lon - 105.0, lat - 35.0)
rl = lat / 180 * math.pi
m = math.sin(rl)
m = 1 - ee * m * m
sm = math.sqrt(m)
return (round(lat + (dl * 180) / ((a * (1 - ee)) / (m * sm) * math.pi), 6),
round(lon + (dn * 180) / (a / sm * math.cos(rl) * math.pi), 6))
def haversine(a, b, c, d):
R = 6371.0088
p1, p2 = math.radians(a), math.radians(c)
dp, dl = math.radians(c - a), math.radians(d - b)
x = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(x)) * 1000
def read_exif(dirs):
"""读出照片目录里所有带 GPS 的坐标(四舍五入到 6 位,与导入页 toFixed(6) 对齐)。"""
try:
from PIL import Image
except ImportError:
raise SystemExit("需要 Pillow:pip install pillow")
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
pts, skipped = [], 0
for root_dir in dirs:
for root, _, files in os.walk(root_dir):
for fn in sorted(files):
if not fn.lower().endswith((".jpg", ".jpeg", ".heic", ".heif", ".png")):
continue
path = os.path.join(root, fn)
try:
ex = Image.open(path).getexif()
g = ex.get_ifd(34853)
la, lo = g.get(2), g.get(4)
if not la or not lo:
skipped += 1
continue
lat = float(la[0]) + float(la[1]) / 60 + float(la[2]) / 3600
lon = float(lo[0]) + float(lo[1]) / 60 + float(lo[2]) / 3600
if str(g.get(1)).upper().startswith("S"):
lat = -lat
if str(g.get(3)).upper().startswith("W"):
lon = -lon
pts.append({"file": fn, "lat": round(lat, 6), "lon": round(lon, 6),
"taken": (ex.get(36867) or ex.get(306) or "")})
except Exception:
skipped += 1
return pts, skipped
def classify(place, photos):
try:
lat, lon = round(float(place["lat"]), 6), round(float(place["lon"]), 6)
except ValueError:
return "invalid", None
for ph in photos:
if ph["lat"] == lat and ph["lon"] == lon:
return "raw", ph
for ph in photos:
if wgs2gcj(ph["lat"], ph["lon"]) == (lat, lon):
return "gcj", ph
return "unknown", None
def api_coords(site, cookie, lat, lon):
q = urllib.parse.urlencode({"mode": "wgs2gcj", "lat": lat, "lon": lon})
status, _, body = http("%s/api/coords?%s" % (site, q), cookie)
if status != 200:
raise RuntimeError("换算接口 HTTP %d" % status)
d = json.loads(body.decode("utf-8"))
if d.get("error"):
raise RuntimeError(d["error"])
return d
def save_place(site, cookie, p, lat, lon):
form = urllib.parse.urlencode({
"src_pk": p["src_pk"], "name": p["name"], "category": p["category"],
"lat": "%.6f" % lat, "lon": "%.6f" % lon,
"province": p["province"], "city": p["city"], "district": p["district"],
}).encode("utf-8")
status, headers, _ = http("%s/admin/edit/place/update" % site, cookie, data=form, timeout=60)
loc = headers.get("Location", "")
if status not in (302, 303):
return False, "HTTP %d" % status
if "err=" in loc:
return False, urllib.parse.unquote(loc.split("err=")[-1])
return True, urllib.parse.unquote(loc.split("ok=")[-1]) if "ok=" in loc else "ok"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--site", required=True, help="站点根地址,如 https://life.shiroko.one")
ap.add_argument("--cookie", default=os.environ.get("ROND_COOKIE", ""),
help="rond_session 的值(也可用环境变量 ROND_COOKIE)")
ap.add_argument("--photos", action="append", default=[], help="原图目录,可重复")
ap.add_argument("--baseline-max-sp", type=int, default=323,
help="rond 基线里 ZLOCATION 的 Z_PK 上限,大于它的都是站点新建")
ap.add_argument("--out", default="photo-coord-fix.csv")
ap.add_argument("--apply", action="store_true", help="真的写库(不加则只出清单)")
ap.add_argument("--include-unverified", action="store_true",
help="连没有原图可核对的点一起换算(风险自负)")
ap.add_argument("--only", default="", help="只处理这些 src_pk,逗号分隔")
ap.add_argument("--sleep", type=float, default=0.15, help="每次写库之间的间隔秒数")
args = ap.parse_args()
site = args.site.rstrip("/")
if not args.cookie:
raise SystemExit("缺少 --cookie(浏览器登录后复制 rond_session 的值)")
places = fetch_places(site, args.cookie)
created = [p for p in places if p["src_pk"] > args.baseline_max_sp]
print("地点总数 %d,其中站点新建(sp > %d)%d 个"
% (len(places), args.baseline_max_sp, len(created)))
photos = []
if args.photos:
photos, skipped = read_exif(args.photos)
print("原图:读到 %d 张带 GPS 的照片(%d 张无 GPS/读不了)" % (len(photos), skipped))
rows = []
for p in created:
verdict, ph = classify(p, photos)
new = wgs2gcj(round(float(p["lat"]), 6), round(float(p["lon"]), 6))
moved = haversine(round(float(p["lat"]), 6), round(float(p["lon"]), 6), new[0], new[1])
rows.append({**p, "verdict": verdict, "photo": ph["file"] if ph else "",
"new_lat": new[0], "new_lon": new[1], "moved": moved})
todo = [r for r in rows if r["verdict"] == "raw"]
if args.include_unverified:
todo += [r for r in rows if r["verdict"] == "unknown"]
if args.only:
want = {int(x) for x in args.only.replace(" ", "").split(",") if x}
todo = [r for r in todo if r["src_pk"] in want]
n_raw = sum(1 for r in rows if r["verdict"] == "raw")
n_gcj = sum(1 for r in rows if r["verdict"] == "gcj")
n_unk = sum(1 for r in rows if r["verdict"] == "unknown")
print("判定:没转换 %d | 已正确 %d | 无原图可核 %d" % (n_raw, n_gcj, n_unk))
with io.open(args.out, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["src_pk", "名称", "城市", "判定", "原图", "现在纬度", "现在经度",
"换算后纬度", "换算后经度", "移动(米)"])
for r in sorted(rows, key=lambda x: x["src_pk"]):
w.writerow([r["src_pk"], r["name"], r["city"],
{"raw": "没转换", "gcj": "已正确", "unknown": "无原图", "invalid": "坐标无效"}[r["verdict"]],
r["photo"], r["lat"], r["lon"],
"%.6f" % r["new_lat"], "%.6f" % r["new_lon"], "%.0f" % r["moved"]])
print("清单已写:%s" % args.out)
for r in todo[:10]:
print(" sp=%-5s %-20s %s,%s → %.6f,%.6f (%.0fm)"
% (r["src_pk"], r["name"][:18], r["lat"], r["lon"], r["new_lat"], r["new_lon"], r["moved"]))
if len(todo) > 10:
print(" …共 %d 条" % len(todo))
if not args.apply:
print("\n[dry-run] 加 --apply 才会写库。")
return
if not todo:
print("\n没有需要处理的点。")
return
applied, failed = [], []
for i, r in enumerate(todo, 1):
try:
d = api_coords(site, args.cookie, r["lat"], r["lon"])
ok, msg = save_place(site, args.cookie, r, d["lat"], d["lon"])
except Exception as e:
ok, msg, d = False, str(e), None
if ok:
applied.append({**r, "applied_lat": "%.6f" % d["lat"], "applied_lon": "%.6f" % d["lon"]})
sys.stderr.write("\r已改 %d/%d sp=%s %s " % (i, len(todo), r["src_pk"], msg[:40]))
else:
failed.append({**r, "err": msg})
sys.stderr.write("\r失败 %d/%d sp=%s %s " % (i, len(todo), r["src_pk"], msg[:40]))
sys.stderr.flush()
time.sleep(args.sleep)
print()
log = args.out + ".applied.csv"
with io.open(log, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["src_pk", "名称", "原纬度", "原经度", "新纬度", "新经度", "回退用经度", "回退用纬度"])
for r in applied:
w.writerow([r["src_pk"], r["name"], r["lat"], r["lon"],
r["applied_lat"], r["applied_lon"], r["lat"], r["lon"]])
print("成功 %d 条,失败 %d 条;回退清单:%s" % (len(applied), len(failed), log))
for r in failed:
print(" 失败 sp=%s %s:%s" % (r["src_pk"], r["name"][:16], r["err"]))
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
import zipfile, sqlite3, os, shutil, sys, tempfile
A = sys.argv[1] if len(sys.argv) > 1 else r"C:/OtherFiles/Github/rond-with-you/data/20260914_151649.rondbackup"
B = sys.argv[2] if len(sys.argv) > 2 else r"C:/OtherFiles/Github/rond-with-you/storage/export-final.rondbackup"
MANAGED = ["ZLOCATION", "ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZACTIVITY", "ZTAG", "ZTAGGROUP", "ZTRANSPORT"]
UNMANAGED = ["ZRAWVISIT", "ZKEYWORD", "ZPOICATEGORY", "ZACTIVITYGROUP"]
tmp = tempfile.mkdtemp(prefix="rondcmp")
def extract(path, tag):
d = os.path.join(tmp, tag)
os.makedirs(d, exist_ok=True)
with zipfile.ZipFile(path) as z:
z.extractall(d)
for f in os.listdir(d):
if f.endswith(".sqlite"):
return os.path.join(d, f)
for root, dirs, files in os.walk(d):
for f in files:
if f.endswith(".sqlite"):
return os.path.join(root, f)
raise SystemExit("no sqlite in " + path)
sa = extract(A, "orig")
sb = extract(B, "gen")
ca = sqlite3.connect(sa)
cb = sqlite3.connect(sb)
diff_total = 0
def rows(c, table):
cur = c.execute('SELECT * FROM "%s"' % table)
cols = [d[0] for d in cur.description]
out = {}
for r in cur.fetchall():
out[r[0]] = dict(zip(cols, r))
return out
def norm(v):
if isinstance(v, float):
return round(v, 6)
if isinstance(v, bytes):
return v
return v
def cmp(table):
global diff_total
ra = rows(ca, table)
rb = rows(cb, table)
ca_ = sqlite3.connect(sa).execute('PRAGMA table_info("%s")' % table).fetchall()
cols = [x[1] for x in ca_]
miss = [k for k in ra if k not in rb]
extra = [k for k in rb if k not in ra]
vdiff = []
for k in ra:
if k not in rb:
continue
d = []
for c in cols:
va, vb = norm(ra[k].get(c)), norm(rb[k].get(c))
if isinstance(va, bytes) or isinstance(vb, bytes):
if va != vb:
d.append((c, type(va).__name__, type(vb).__name__))
continue
if va != vb:
d.append((c, va, vb))
if d:
vdiff.append((k, d))
n = len(miss) + len(extra) + len(vdiff)
diff_total += n
print("[%s] rows orig=%d gen=%d missing=%d extra=%d valuediff=%d" % (table, len(ra), len(rb), len(miss), len(extra), len(vdiff)))
if miss:
print(" missing PKs:", miss[:10])
if extra:
print(" extra PKs:", extra[:10])
for k, d in vdiff[:3]:
print(" PK %s differs: %s" % (k, d[:8]))
print("=== MANAGED ===")
for t in MANAGED:
cmp(t)
print("=== UNMANAGED ===")
for t in UNMANAGED:
cmp(t)
# 结构对比
def struct(c):
t = sorted(x[0] for x in c.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall())
i = sorted(x[0] for x in c.execute("SELECT name FROM sqlite_master WHERE type='index'").fetchall())
return t, i
ta, ia = struct(ca)
tb, ib = struct(cb)
print("tables: %d vs %d equal=%s" % (len(ta), len(tb), ta == tb))
if ta != tb:
print(" only-orig:", [x for x in ta if x not in tb])
print(" only-gen :", [x for x in tb if x not in ta])
print("indexes: %d vs %d equal=%s" % (len(ia), len(ib), ia == ib))
if ia != ib:
print(" only-orig:", [x for x in ia if x not in ib])
print(" only-gen :", [x for x in ib if x not in ia])
ma = ca.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]
mb = cb.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]
print("Z_METADATA plist: %d vs %d bytes equal=%s" % (len(ma), len(mb), ma == mb))
print("总差异 =", diff_total)
shutil.rmtree(tmp, ignore_errors=True)