Files
shaguabijia-app-server/scripts/load_meituan_coupon_tsv.py
T
guke 7b6756f936 feat(meituan-cps): 经纬度→城市离线反查 + rec/销量最高按城市过滤 (#116)
## 主要功能
新增离线「经纬度 → 美团 cityId」反查,让 `rec`(智能推荐)与 `top-sales`(销量最高)从离线库只返**同城券**(此前会混返异地券)。

- `app/utils/geo.py` + `app/utils/meituan_city.py`:坐标 → 美团 `city_id`(reverse_geocoder 离线反查,零网络)。
- `feed?tab=rec` / `/top-sales`:按 `city_id` 过滤;解析不出 / 老客户端不带坐标 → 降级返空。
- `top-sales` 与 `rec` 一致置空库内距离(相对城市默认点、对用户无意义)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #116
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-07-05 09:31:53 +08:00

171 lines
6.8 KiB
Python

"""把 meituan_coupon 的线上采样 TSV 灌进本地 SQLite。
用途:本地开发/调试时,把线上 `meituan_coupon` 表的采样数据(tests/meituan_coupon_data.tsv)
灌进 dev 库(默认 `./data/app.db`),免得每次都实时打美团接口。
TSV 说明:
- 制表符分隔,每行一条记录,列顺序与线上 PostgreSQL 物理列一致
(image_size / image_type 是后加的迁移,排在最后两列 —— 与本地 SQLite 一致)。
- 空字段 = NULL(文件里没有 `\\N` 标记)。
- 个别记录的文本/JSON 字段内含换行,会把一条逻辑行拆成多物理行 —— 按“累计到 26 列”重组。
- 文件尾部可能有一条被导出截断的残行(列数不足 / raw JSON 不完整),直接跳过。
datetime 三列(first_seen/last_seen/updated_at)去掉尾部时区偏移(`+08`),
存成 SQLAlchemy 在 SQLite 上用的朴素格式 `YYYY-MM-DD HH:MM:SS.ffffff`,保证 ORM 能读回。
用法:
python scripts/load_meituan_coupon_tsv.py # 默认 TSV + .env 里的库
python scripts/load_meituan_coupon_tsv.py path/to.tsv # 指定 TSV
DATABASE_URL=sqlite:///./data/app.db python scripts/load_meituan_coupon_tsv.py
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
# 列顺序 = TSV 字段顺序 = 本地 SQLite 物理列顺序
COLS = [
"id", "source", "platform", "biz_line", "city_id", "product_view_sign",
"sku_view_id", "name", "brand_name", "sell_price_cents", "original_price_cents",
"head_url", "sale_volume", "sale_volume_num", "commission_percent",
"commission_amount_cents", "poi_name", "available_poi_num", "delivery_distance_m",
"dedup_key", "raw", "first_seen", "last_seen", "updated_at", "image_size", "image_type",
]
NCOL = len(COLS)
# 按列做类型转换(空串 -> None)。未列出的列 = 原样字符串(source/city_id/... 等 NOT NULL 文本)。
_INT_COLS = {0, 2, 3, 9, 10, 13, 15, 17, 24} # id, platform, biz_line, prices, ...
_FLOAT_COLS = {14, 18} # commission_percent, delivery_distance_m
_NULLABLE_STR_COLS = {6, 7, 8, 11, 12, 16, 25} # sku_view_id, name, brand_name, ...
_DT_COLS = {21, 22, 23} # first_seen, last_seen, updated_at
_RAW_COL = 20
_TZ_SUFFIX = re.compile(r"[+-]\d{2}(:?\d{2})?$") # 尾部时区偏移 +08 / +08:00 / +0800
def _resolve_sqlite_path() -> Path:
"""从 DATABASE_URL(env 或 .env)解析出 SQLite 文件路径。只支持 sqlite://。"""
url = os.environ.get("DATABASE_URL", "")
if not url:
env = _PROJECT_ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("DATABASE_URL="):
url = line.split("=", 1)[1].strip()
break
if not url:
url = "sqlite:///./data/app.db"
if not url.startswith("sqlite:"):
sys.exit(f"仅支持 sqlite:// 库,当前 DATABASE_URL={url!r}")
rest = url.split("sqlite:///", 1)[1] if "sqlite:///" in url else url.split("sqlite://", 1)[1]
p = Path(rest)
if not p.is_absolute():
p = (_PROJECT_ROOT / rest).resolve()
return p
def _reconstruct_rows(text: str) -> tuple[list[list[str]], int]:
"""把文件文本重组成一条条 26 列的逻辑行。返回 (rows, skipped)。
单个字段内含换行 -> 一条逻辑行被拆成多物理行:累计字段,拆点用 \\n 重新拼回,
直到凑满 26 列。列数溢出(内嵌 TAB / 错位)或文件尾残行 -> 跳过并计数。
"""
lines = text.split("\n")
while lines and lines[-1] == "":
lines.pop()
rows: list[list[str]] = []
skipped = 0
buf: list[str] = []
for raw_line in lines:
parts = raw_line.split("\t")
if not buf:
buf = parts
else:
buf[-1] += "\n" + parts[0] # 拼回被换行拆开的字段
buf.extend(parts[1:])
if len(buf) == NCOL:
rows.append(buf)
buf = []
elif len(buf) > NCOL: # 溢出:数据异常,丢弃这段重新开始
print(f" [skip] 列数溢出({len(buf)}>{NCOL}),field0={buf[0][:20]!r}")
skipped += 1
buf = []
if buf: # 文件尾被截断的残行
print(f" [skip] 尾部残行不足 {NCOL} 列(实 {len(buf)} 列),field0={buf[0][:20]!r}")
skipped += 1
return rows, skipped
def _convert(row: list[str]) -> tuple | None:
"""按列类型转换一行;非法(必填 int 为空 / raw 非 JSON)返回 None。"""
out: list = []
for i, v in enumerate(row):
if i in _DT_COLS:
out.append(_TZ_SUFFIX.sub("", v))
continue
if i == _RAW_COL:
try:
json.loads(v)
except Exception as e:
print(f" [skip] id={row[0]} raw 非法 JSON: {e}")
return None
out.append(v)
continue
if i in _INT_COLS:
out.append(int(v) if v != "" else None)
elif i in _FLOAT_COLS:
out.append(float(v) if v != "" else None)
elif i in _NULLABLE_STR_COLS:
out.append(v if v != "" else None)
else: # 必填文本列,原样
out.append(v)
return tuple(out)
def main() -> None:
tsv = Path(sys.argv[1]) if len(sys.argv) > 1 else _PROJECT_ROOT / "tests" / "meituan_coupon_data.tsv"
if not tsv.is_absolute():
tsv = (_PROJECT_ROOT / tsv).resolve()
db = _resolve_sqlite_path()
print(f"TSV: {tsv}")
print(f"DB : {db}")
if not tsv.exists():
sys.exit(f"TSV 不存在: {tsv}")
if not db.exists():
sys.exit(f"SQLite 库不存在: {db}(先跑 alembic upgrade head 建表)")
rows, skipped = _reconstruct_rows(tsv.read_text(encoding="utf-8"))
print(f"重组逻辑行: {len(rows)} 跳过(残/异常): {skipped}")
records = []
bad = 0
for r in rows:
rec = _convert(r)
if rec is None:
bad += 1
continue
records.append(rec)
print(f"可入库: {len(records)} 转换失败: {bad}")
placeholders = ",".join(["?"] * NCOL)
sql = f"INSERT OR REPLACE INTO meituan_coupon ({','.join(COLS)}) VALUES ({placeholders})"
con = sqlite3.connect(str(db))
try:
before = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
con.executemany(sql, records)
con.commit()
after = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
finally:
con.close()
print(f"入库前 {before} 行 -> 入库后 {after} 行(本次 {len(records)} 条)")
if __name__ == "__main__":
main()