memsave 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memsave/__init__.py +12 -0
- memsave/__main__.py +3 -0
- memsave/afdian.py +310 -0
- memsave/billing.py +574 -0
- memsave/budget.py +71 -0
- memsave/cli.py +360 -0
- memsave/distill.py +211 -0
- memsave/embed.py +191 -0
- memsave/export.py +144 -0
- memsave/license.py +200 -0
- memsave/ops.py +392 -0
- memsave/server.py +287 -0
- memsave/store.py +548 -0
- memsave/tokenize.py +116 -0
- memsave/webhook.py +246 -0
- memsave-0.2.0.dist-info/METADATA +281 -0
- memsave-0.2.0.dist-info/RECORD +21 -0
- memsave-0.2.0.dist-info/WHEEL +5 -0
- memsave-0.2.0.dist-info/entry_points.txt +2 -0
- memsave-0.2.0.dist-info/licenses/LICENSE +202 -0
- memsave-0.2.0.dist-info/top_level.txt +1 -0
memsave/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""memsave — read your memories without spending credits.
|
|
2
|
+
|
|
3
|
+
Retrieval runs entirely on local CPU: SQLite FTS5/BM25 for keywords, an
|
|
4
|
+
optional 24MB ONNX model for semantics. Only the handful of tokens that
|
|
5
|
+
actually matched get handed to the model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .store import Memory, Hit, Store
|
|
9
|
+
from .budget import estimate_tokens
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
__all__ = ["Memory", "Hit", "Store", "estimate_tokens"]
|
memsave/__main__.py
ADDED
memsave/afdian.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""爱发电(afdian.com)收款通道。
|
|
2
|
+
|
|
3
|
+
## 这里有个必须先说清楚的事实
|
|
4
|
+
|
|
5
|
+
**爱发电的 webhook 没有任何签名。** 官方文档里的签名机制只用于「你主动调用 API」,
|
|
6
|
+
不用于「平台 POST 给你的 webhook」。也就是说:
|
|
7
|
+
|
|
8
|
+
> 任何人只要拿到你的 webhook URL,就能 POST 一个假订单,骗走一把 Pro 授权码。
|
|
9
|
+
|
|
10
|
+
URL 保密不算防御 —— 它会出现在服务器日志、CDN 日志、浏览器历史里。
|
|
11
|
+
|
|
12
|
+
所以本模块的设计核心是:**收到 webhook 后用带签名的 API 反向回查订单。**
|
|
13
|
+
伪造者 POST 得再像真的,他也过不了 API 回查那一关,因为回查要你的 api token。
|
|
14
|
+
|
|
15
|
+
官方文档自己也建议这么做:「建议结合 API 一起使用」。
|
|
16
|
+
|
|
17
|
+
## 另一个必须挡住的坑
|
|
18
|
+
|
|
19
|
+
创作者通常还有一个低价的月度订阅方案(比如「请作者喝杯咖啡 ¥5/月」)。
|
|
20
|
+
**订阅订单也会推送到同一个 webhook。** 不做区分的话,
|
|
21
|
+
一个每月付 5 元的咖啡党,会收到一把 ¥99 的 Pro 授权码。
|
|
22
|
+
|
|
23
|
+
所以每个订单都要过三道闸:类型、商品名、金额。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
|
|
36
|
+
API_BASE = "https://afdian.com/api/open"
|
|
37
|
+
|
|
38
|
+
# 订单必须命中这些商品名才会发码。小写匹配,顺序无关。
|
|
39
|
+
# 在 billing.env 里用 AFDIAN_SKU_PRO / AFDIAN_SKU_TEAM 覆盖。
|
|
40
|
+
DEFAULT_SKUS = {"pro": ("pro", "终身", "lifetime"), "team": ("team", "团队")}
|
|
41
|
+
|
|
42
|
+
# 各 tier 的最低成交金额(元)。低于此值不发码 —— 挡住 0 元/折扣/咖啡订单。
|
|
43
|
+
# 在 billing.env 里用 AFDIAN_MIN_AMOUNT_PRO / AFDIAN_MIN_AMOUNT_TEAM 覆盖。
|
|
44
|
+
DEFAULT_MIN_AMOUNT = {"pro": 79.0, "team": 199.0}
|
|
45
|
+
|
|
46
|
+
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
47
|
+
|
|
48
|
+
# 订单状态。文档说目前仅推送 status=2(交易成功),但保险起见还是要查。
|
|
49
|
+
STATUS_PAID = 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AfdianError(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _env(name: str, default: str = "") -> str:
|
|
57
|
+
return os.environ.get(name, default).strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _skus_for(tier: str) -> tuple[str, ...]:
|
|
61
|
+
raw = _env(f"AFDIAN_SKU_{tier.upper()}")
|
|
62
|
+
if raw:
|
|
63
|
+
return tuple(s.strip().lower() for s in raw.split(",") if s.strip())
|
|
64
|
+
return DEFAULT_SKUS.get(tier, ())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _min_amount(tier: str) -> float:
|
|
68
|
+
raw = _env(f"AFDIAN_MIN_AMOUNT_{tier.upper()}")
|
|
69
|
+
if raw:
|
|
70
|
+
try:
|
|
71
|
+
return float(raw)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
return DEFAULT_MIN_AMOUNT.get(tier, 0.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------- 签名
|
|
78
|
+
|
|
79
|
+
def sign(token: str, params: str, ts: int, user_id: str) -> str:
|
|
80
|
+
"""爱发电 API 签名。
|
|
81
|
+
|
|
82
|
+
sign = md5(token + "params" + params + "ts" + ts + "user_id" + user_id)
|
|
83
|
+
|
|
84
|
+
注意 key 和 value 直接拼接,没有任何连接符,value 也要原样带上 key 名。
|
|
85
|
+
文档给出的向量:
|
|
86
|
+
sign('123', '{"a":333}', 1624339905, 'abc')
|
|
87
|
+
== 'a4acc28b81598b7e5d84ebdc3e91710c'
|
|
88
|
+
"""
|
|
89
|
+
raw = f"{token}params{params}ts{ts}user_id{user_id}"
|
|
90
|
+
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def signed_body(user_id: str, token: str, params: dict, ts: int | None = None) -> dict:
|
|
94
|
+
"""构造一个可直接 POST 的请求体。params 会被序列化成紧凑 JSON。"""
|
|
95
|
+
s = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
|
|
96
|
+
ts = int(ts if ts is not None else time.time())
|
|
97
|
+
return {
|
|
98
|
+
"user_id": user_id,
|
|
99
|
+
"params": s,
|
|
100
|
+
"ts": ts,
|
|
101
|
+
"sign": sign(token, s, ts, user_id),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _api(endpoint: str, body: dict, timeout: float = 15.0) -> dict:
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
f"{API_BASE}/{endpoint}",
|
|
108
|
+
data=json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8"),
|
|
109
|
+
headers={
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
"User-Agent": "memsave-billing/0.2",
|
|
112
|
+
},
|
|
113
|
+
method="POST",
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
117
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
118
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
119
|
+
raise AfdianError(f"{endpoint} 请求失败: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ping() -> dict:
|
|
123
|
+
"""校验 user_id + token 是否配对。返回平台原始响应。"""
|
|
124
|
+
user_id, token = credentials()
|
|
125
|
+
return _api("ping", signed_body(user_id, token, {"a": 333}))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def credentials() -> tuple[str, str]:
|
|
129
|
+
user_id = _env("AFDIAN_USER_ID")
|
|
130
|
+
token = _env("AFDIAN_API_TOKEN")
|
|
131
|
+
if not user_id or not token:
|
|
132
|
+
raise AfdianError("未配置 AFDIAN_USER_ID / AFDIAN_API_TOKEN")
|
|
133
|
+
return user_id, token
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def query_order(out_trade_no: str) -> dict | None:
|
|
137
|
+
"""按订单号回查。三种结果必须分清楚,否则整个防伪造机制失效:
|
|
138
|
+
|
|
139
|
+
- 找到了 -> 返回订单 dict
|
|
140
|
+
- 确实没这个订单(ec=200 但列表为空)-> None,调用方当作伪造拦截
|
|
141
|
+
- **平台报错 / 签名被拒 / 网络故障** -> 抛 AfdianError
|
|
142
|
+
|
|
143
|
+
第三点是最关键的。早期版本把「API 报错」和「订单不存在」都压成 None,
|
|
144
|
+
后果是:token 配错了没人知道,客户付了钱、码没发、日志干干净净。
|
|
145
|
+
现在报错必须向上抛,让调用方返回 500 触发平台重试,并在日志里炸出来。
|
|
146
|
+
"""
|
|
147
|
+
user_id, token = credentials()
|
|
148
|
+
resp = _api("query-order", signed_body(user_id, token, {"out_trade_no": out_trade_no}))
|
|
149
|
+
ec = resp.get("ec")
|
|
150
|
+
if ec != 200:
|
|
151
|
+
raise AfdianError(f"query-order 返回 ec={ec} em={resp.get('em', '')!r}"
|
|
152
|
+
f"(检查 AFDIAN_USER_ID / AFDIAN_API_TOKEN 是否配对)")
|
|
153
|
+
data = resp.get("data") or {}
|
|
154
|
+
for order in data.get("list") or []:
|
|
155
|
+
if order.get("out_trade_no") == out_trade_no:
|
|
156
|
+
return order
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fetch_orders(pages: int = 3) -> list[dict]:
|
|
161
|
+
"""按分页拉取订单列表(不传 out_trade_no 就是查列表)。
|
|
162
|
+
|
|
163
|
+
存在的理由:webhook 是"推送",推送的前提是我们的服务当时在线。
|
|
164
|
+
本机跑 + 免费隧道 = 电脑关机就收不到。所以对账必须能反过来
|
|
165
|
+
「主动去平台把订单拉回来」,否则漏单就是漏钱,而且客户不知道自己漏了。
|
|
166
|
+
|
|
167
|
+
配合 billing.reconcile 使用:拉取 → 逐单走同一套闸门和幂等账本 →
|
|
168
|
+
只补发本地没有的那些。
|
|
169
|
+
"""
|
|
170
|
+
user_id, token = credentials()
|
|
171
|
+
out: list[dict] = []
|
|
172
|
+
total: int | None = None
|
|
173
|
+
for page in range(1, max(1, pages) + 1):
|
|
174
|
+
resp = _api("query-order", signed_body(user_id, token, {"page": page}))
|
|
175
|
+
if resp.get("ec") != 200:
|
|
176
|
+
raise AfdianError(
|
|
177
|
+
f"query-order 返回 ec={resp.get('ec')} em={resp.get('em', '')!r}"
|
|
178
|
+
f"(检查 AFDIAN_USER_ID / AFDIAN_API_TOKEN 是否配对)"
|
|
179
|
+
)
|
|
180
|
+
data = resp.get("data") or {}
|
|
181
|
+
if total is None:
|
|
182
|
+
try:
|
|
183
|
+
total = int(data.get("total_count") or 0)
|
|
184
|
+
except (TypeError, ValueError):
|
|
185
|
+
total = 0
|
|
186
|
+
lst = data.get("list") or []
|
|
187
|
+
out.extend(lst)
|
|
188
|
+
if not lst or (total and len(out) >= total):
|
|
189
|
+
break
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------- webhook
|
|
194
|
+
|
|
195
|
+
def parse_webhook(payload: dict) -> dict | None:
|
|
196
|
+
"""从 webhook body 里抽出我们要的东西。
|
|
197
|
+
|
|
198
|
+
不是订单事件(文档说目前只有 order,但以后可能扩展)就返回 None。
|
|
199
|
+
"""
|
|
200
|
+
data = payload.get("data") or {}
|
|
201
|
+
if data.get("type") != "order":
|
|
202
|
+
return None
|
|
203
|
+
order = data.get("order") or {}
|
|
204
|
+
if not order.get("out_trade_no"):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
skus = [
|
|
208
|
+
(s.get("name") or "").strip()
|
|
209
|
+
for s in (order.get("sku_detail") or [])
|
|
210
|
+
if isinstance(s, dict)
|
|
211
|
+
]
|
|
212
|
+
return {
|
|
213
|
+
"order_id": str(order.get("out_trade_no")),
|
|
214
|
+
"buyer_id": str(order.get("user_id") or ""),
|
|
215
|
+
"remark": str(order.get("remark") or ""),
|
|
216
|
+
"amount": _to_float(order.get("total_amount")),
|
|
217
|
+
"status": int(order.get("status") or 0),
|
|
218
|
+
"product_type": int(order.get("product_type") or 0),
|
|
219
|
+
"skus": skus,
|
|
220
|
+
# 自定义信息收集(下单必填的「邮箱」字段)落在哪,官方文档没写死。
|
|
221
|
+
# 社区实测它出现在 custom_order_id;保险起见把所有可能藏文本的
|
|
222
|
+
# 字段都带回去,extract_email 逐个试。
|
|
223
|
+
"custom_order_id": str(order.get("custom_order_id") or ""),
|
|
224
|
+
"address_person": str(order.get("address_person") or ""),
|
|
225
|
+
"address_phone": str(order.get("address_phone") or ""),
|
|
226
|
+
"address_address": str(order.get("address_address") or ""),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def extract_email_from(order: dict) -> str | None:
|
|
231
|
+
"""按可靠度排序,从订单的多个文本字段里抠邮箱。
|
|
232
|
+
|
|
233
|
+
优先级:custom_order_id(自定义信息收集的必填邮箱,最可靠)
|
|
234
|
+
> remark(留言,商品说明里要求填的兜底)
|
|
235
|
+
> 收件人字段(几乎不会有人把邮箱填这里,但看一眼不花钱)
|
|
236
|
+
"""
|
|
237
|
+
for key in ("custom_order_id", "remark", "address_person", "address_address"):
|
|
238
|
+
email = extract_email(order.get(key, ""))
|
|
239
|
+
if email:
|
|
240
|
+
return email
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _to_float(v) -> float:
|
|
245
|
+
try:
|
|
246
|
+
return float(v)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
return 0.0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def extract_email(text: str) -> str | None:
|
|
252
|
+
"""爱发电订单里没有邮箱字段,只有昵称。
|
|
253
|
+
|
|
254
|
+
所以商品的购买说明里必须写清楚「下单请在留言栏填邮箱」,
|
|
255
|
+
然后这里从留言里把它抠出来。这是社区里验证过的做法。
|
|
256
|
+
"""
|
|
257
|
+
m = EMAIL_RE.search(text or "")
|
|
258
|
+
return m.group(0).lower() if m else None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def tier_for(skus: list[str]) -> str | None:
|
|
262
|
+
"""按 sku 商品名匹配 tier。命中多个时返回价高的那个(Pro 优先于终身字样)。"""
|
|
263
|
+
hit: list[str] = []
|
|
264
|
+
joined = " ".join(skus).lower()
|
|
265
|
+
for tier in ("team", "pro"):
|
|
266
|
+
if any(k in joined for k in _skus_for(tier)):
|
|
267
|
+
hit.append(tier)
|
|
268
|
+
if not hit:
|
|
269
|
+
return None
|
|
270
|
+
# 同时命中两档时按高价档发,宁可多发也别少发
|
|
271
|
+
return "team" if "team" in hit else hit[0]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def gate(order: dict, skus: list[str]) -> tuple[bool, str]:
|
|
275
|
+
"""三道闸:类型 → 商品名 → 金额。返回 (是否放行, 原因)。
|
|
276
|
+
|
|
277
|
+
挡住两类误发:
|
|
278
|
+
- 月度订阅的「咖啡党」订单(product_type=0,没有 sku)
|
|
279
|
+
- 折扣/兑换码导致的低价单
|
|
280
|
+
"""
|
|
281
|
+
if order.get("status", 0) != STATUS_PAID:
|
|
282
|
+
return False, f"订单状态 {order.get('status')} 非已支付"
|
|
283
|
+
|
|
284
|
+
tier = tier_for(skus)
|
|
285
|
+
if tier is None:
|
|
286
|
+
return False, "商品名未命中任何授权档位(可能是月度订阅或无关商品)"
|
|
287
|
+
|
|
288
|
+
if not skus:
|
|
289
|
+
return False, "无 sku_detail,视为订阅订单,不发码"
|
|
290
|
+
|
|
291
|
+
floor = _min_amount(tier)
|
|
292
|
+
if order.get("amount", 0.0) < floor:
|
|
293
|
+
return False, f"金额 {order.get('amount')} 低于 {tier} 档下限 {floor}"
|
|
294
|
+
|
|
295
|
+
return True, tier
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ok_response() -> dict:
|
|
299
|
+
"""平台要求的成功响应格式。不返回 ec=200 它会当失败并重推。"""
|
|
300
|
+
return {"ec": 200, "em": ""}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def fail_response() -> dict:
|
|
304
|
+
"""明确告诉平台这次失败了,让它重试。
|
|
305
|
+
|
|
306
|
+
只在「我们确实没处理成功且重试有希望」时用。
|
|
307
|
+
对于伪造订单,应返回 200 —— 否则伪造者能靠反复重推把服务器打满,
|
|
308
|
+
而重推一万次也变不成真订单。
|
|
309
|
+
"""
|
|
310
|
+
return {"ec": 400, "em": "retry"}
|