Files
rond-with-you/tools/dedup_site_visits.py
T
tamakyi 40441ba900 工具:tools/dedup_site_visits.py —— 以手机包为参照清掉站点侧的历史重复到访
站点数据里有一批「到达时间 + 地点与手机包里某条完全相同、但 UUID 不同」的到访副本(站点侧生成的
UUID)。差异页/合并按 UUID 认人,于是它们被当成「备份新增」,导出到 rond 后就是成对的两条重复记录
(2026-09-29 站长实测:导入后 app 里出现成对的重复条目)。

工具以手机导出的包为参照(数据集的基线):
  * 站点到访的 UUID 在参照里能找到 → 保留(手机自己也有成对条目,那是合法的,不能动)
  * UUID 找不到、且 (到达时间, 地点) 与参照里某条一致 → 判为副本
副本的附属记录(位移/天气/留档)一并删除;若有别的到访的留档指向副本(ZMERGEDTO / ZPARENT),
改指到保留的那条,避免导出时留下悬空引用。**默认只预演,--apply 才动手。**

踩坑记录(都在此提交里修掉):
  * `raw->>` 取出的是 text,与数字比较会报「text = integer 操作符不存在」,导致工具在动手前整个退出;
  * 同一个 UPDATE 里不能给 raw 赋两次值(「对同一列 raw 进行了多次分配」),jsonb_set 要嵌套。

演练验证(本地真实数据:导入含 72 条副本的包 → 执行 → 核对):
  到访 1026 → 954、位移 833 → 809、天气 621 → 589(与手机包一致);
  「同时间同地点」重复组 101 → **31**(= 手机包自身)、悬空引用 0/0。
2026-09-29 17:38:50 +08:00

161 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""清掉「同一次到访在站点库里存了两份」的历史副本。
背景(2026-09-29):站点数据里存在一批**到达时间 + 地点与手机包里某条完全相同、但 UUID 不同**的
到访副本(站点侧生成的 UUID)。差异页/合并按 UUID 认人,于是它们会被当成「备份新增」,
导出到 rond 后就是成对的两条重复记录。
本工具以**手机导出的包**为参照(也就是这份数据集从手机那儿来的那个 store,即数据集的基线):
* 站点到访的 UUID **在参照里能找到** → 保留(手机自己也有成对条目,那是合法的,不能动);
* UUID **找不到**、且 (到达时间, 地点) 与参照里某条一致 → 判为副本。
副本的附属记录(位移 / 天气 / 留档)一并删除;若有**别的到访的留档**指向副本(ZMERGEDTO / ZPARENT),
改指到保留下来的那条,避免导出时留下悬空引用。
默认**只预演**(只读,不改库):列出将删除的行。确认无误后加 --apply 真正执行。
用法:
python tools/dedup_site_visits.py --dataset 9 --baseline data/20260928_183908.rondbackup
python tools/dedup_site_visits.py --dataset 9 --baseline data/20260928_183908.rondbackup --apply
"""
import argparse
import os
import sqlite3
import subprocess
import sys
import tempfile
import zipfile
def psql(args, sql, want_rows=False):
cmd = ["psql", "-U", args.user, "-h", args.host, "-p", str(args.port), "-d", args.db, "-v", "ON_ERROR_STOP=1"]
cmd += ["-A", "-F", "\t", "-t"] if want_rows else []
cmd += ["-c", sql]
r = subprocess.run(cmd, capture_output=True, text=True, env=dict(os.environ, PGPASSWORD=args.password))
if r.returncode:
print("SQL 失败:", r.stderr.strip()[:400], file=sys.stderr)
sys.exit(1)
return r.stdout
def load_baseline(path):
"""读手机包的到访身份:UUID 集合 + (到达时间, 地点坐标) → UUID。"""
d = tempfile.mkdtemp(prefix="dedup-base")
with zipfile.ZipFile(path) as z:
z.extractall(d)
main = [os.path.join(r, f) for r, _, fs in os.walk(d) for f in fs if f.endswith(".sqlite")][0]
con = sqlite3.connect(main)
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
con.commit()
places = {pk: (name or "", lat or 0.0, lon or 0.0)
for pk, name, lat, lon in con.execute("SELECT Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE FROM ZLOCATION")}
uuids, by_key = set(), {}
for uuid, arr, loc in con.execute(
"SELECT ZIDENTIFIER_, ZARRIVALDATE_, ZLOCATION FROM ZVISIT WHERE ZIDENTIFIER_ IS NOT NULL"):
u = bytes(uuid).hex()
uuids.add(u)
if arr is not None and loc in places:
name, lat, lon = places[loc]
by_key.setdefault((round(arr, 3), name), u)
if lat or lon:
by_key.setdefault((round(arr, 3), "%.6f|%.6f" % (lat, lon)), u)
con.close()
return uuids, by_key
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dataset", type=int, required=True, help="数据集 id")
ap.add_argument("--baseline", required=True, help="手机导出的 .rondbackup(参照)")
ap.add_argument("--db", default="rond_with_you")
ap.add_argument("--user", default="postgres")
ap.add_argument("--password", default=os.environ.get("PGPASSWORD", ""))
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=5432)
ap.add_argument("--apply", action="store_true", help="真正删除(默认只预演)")
args = ap.parse_args()
uuids, by_key = load_baseline(args.baseline)
print("参照(手机包):到访 %d 条(带 UUID)" % len(uuids))
# 站点侧到访:src_pk / UUID / 到达时间 / 地点名 / 地点坐标
rows = psql(args, """
SELECT v.src_pk,
COALESCE(er.raw #>> '{ZIDENTIFIER_,$blob}',''),
COALESCE(to_char(v.arrival AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US'),''),
COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
(er.raw #>> '{ZMERGEDTO}'), (er.raw #>> '{ZPARENT}')
FROM visits v
LEFT JOIN places p ON p.id = v.place_id
LEFT JOIN entity_raw er ON er.dataset_id = v.dataset_id AND er.entity='ZVISIT' AND er.src_pk = v.src_pk
WHERE v.dataset_id = %d AND v.arrival IS NOT NULL
ORDER BY v.src_pk""" % args.dataset, want_rows=True)
import base64
from datetime import datetime, timezone
def apple_secs(iso):
if not iso:
return None
dt = datetime.strptime(iso[:26], "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=timezone.utc)
return (dt - datetime(2001, 1, 1, tzinfo=timezone.utc)).total_seconds()
visits, victims = [], []
for line in rows.strip().split("\n"):
if not line.strip():
continue
f = line.split("\t")
pk, b64, iso, pname, lat, lon = int(f[0]), f[1], f[2], f[3], float(f[4]), float(f[5])
uuid = base64.b64decode(b64).hex() if b64 else ""
visits.append((pk, uuid, apple_secs(iso), pname, lat, lon, f[6] if len(f) > 6 else "", f[7] if len(f) > 7 else ""))
keep_by_key = {}
for pk, uuid, arr, pname, lat, lon, _, _ in visits:
if uuid in uuids and arr is not None:
keep_by_key.setdefault((round(arr, 3), pname), pk)
if lat or lon:
keep_by_key.setdefault((round(arr, 3), "%.6f|%.6f" % (lat, lon)), pk)
for pk, uuid, arr, pname, lat, lon, merged, parent in visits:
if not uuid or uuid in uuids or arr is None:
continue
keep = by_key.get((round(arr, 3), pname)) or by_key.get((round(arr, 3), "%.6f|%.6f" % (lat, lon)))
keep_pk = keep_by_key.get((round(arr, 3), pname)) or keep_by_key.get((round(arr, 3), "%.6f|%.6f" % (lat, lon)))
if keep and keep_pk:
victims.append((pk, arr, pname, keep_pk))
print("站点侧副本:%d 条" % len(victims))
for pk, arr, pname, keep_pk in victims[:20]:
ts = datetime(2001, 1, 1, tzinfo=timezone.utc).timestamp() + arr
print(" src_pk=%-6d %s %-22s → 保留 src_pk=%d" % (
pk, datetime.fromtimestamp(ts, timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"), pname, keep_pk))
if len(victims) > 20:
print(" …… 其余 %d 条" % (len(victims) - 20))
if not victims:
print("没有发现副本 ✓")
return
if not args.apply:
print("\n(预演结束,未改动任何数据。确认无误后加 --apply 执行)")
return
pks = ",".join(str(v[0]) for v in victims)
keep = {str(v[0]): str(v[3]) for v in victims}
# 1) 别的到访的留档若指向副本,改指到保留项(否则导出会留下悬空引用)
# 注意:raw->> 取出的是 text,必须按字符串比较(拿数字去比会报「text = integer 操作符不存在」,
# 那样工具会在动手前整个退出 —— 第一版就是这么失败了)
for victim, survivor in keep.items():
psql(args, """UPDATE entity_raw
SET raw = jsonb_set(jsonb_set(raw, '{ZMERGEDTO}', to_jsonb(%s::int), true),
'{ZPARENT}', to_jsonb(%s::int), true)
WHERE dataset_id=%d AND entity='ZVISIT'
AND ((raw->>'ZMERGEDTO') = '%s' OR (raw->>'ZPARENT') = '%s')"""
% (survivor, survivor, args.dataset, victim, victim))
# 2) 附属记录:位移、天气、留档
psql(args, "DELETE FROM movements WHERE dataset_id=%d AND (from_visit_src IN (%s) OR to_visit_src IN (%s))"
% (args.dataset, pks, pks))
psql(args, "DELETE FROM weather WHERE dataset_id=%d AND visit_src IN (%s)" % (args.dataset, pks))
psql(args, "DELETE FROM entity_raw WHERE dataset_id=%d AND entity='ZVISIT' AND src_pk IN (%s)" % (args.dataset, pks))
psql(args, "DELETE FROM visits WHERE dataset_id=%d AND src_pk IN (%s)" % (args.dataset, pks))
print("已删除 %d 条副本及其附属记录" % len(victims))
if __name__ == "__main__":
main()