codebee 0.1.66 → 0.1.67

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.
@@ -0,0 +1,228 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Append-only external operation ledger.
3
+
4
+ External writes cannot promise exactly-once delivery. Each write therefore
5
+ has a stable operation id and an explicit state: pending, confirmed, failed or
6
+ unknown. Unknown means the caller must reconcile with the remote system before
7
+ retrying; it is never treated as success.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import secrets
14
+ import threading
15
+ import time
16
+ import urllib.error
17
+ from pathlib import Path
18
+
19
+ from . import paths
20
+
21
+ LOCK = threading.RLock()
22
+ STATUSES = ("pending", "confirmed", "failed", "unknown")
23
+ PENDING_TTL_SECONDS = 15 * 60
24
+
25
+
26
+ def _file(day=None) -> Path:
27
+ day = day or time.strftime("%Y-%m-%d")
28
+ return paths.DATA_DIR / "operations" / ("operations-%s.jsonl" % day[:7].replace("-", ""))
29
+
30
+
31
+ def _read_all():
32
+ rows = []
33
+ root = paths.DATA_DIR / "operations"
34
+ try:
35
+ files = sorted(root.glob("operations-*.jsonl"))
36
+ except OSError:
37
+ files = []
38
+ for fp in files:
39
+ try:
40
+ for line in fp.read_text(encoding="utf-8").splitlines():
41
+ try:
42
+ row = json.loads(line)
43
+ except (TypeError, ValueError):
44
+ continue
45
+ if isinstance(row, dict) and row.get("operation_id"):
46
+ rows.append(row)
47
+ except OSError:
48
+ continue
49
+ return rows
50
+
51
+
52
+ def _write_all(rows):
53
+ """Test/support migration helper; production writes use append-only _append."""
54
+ root = paths.DATA_DIR / "operations"
55
+ root.mkdir(parents=True, exist_ok=True)
56
+ fp = _file()
57
+ fp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows),
58
+ encoding="utf-8")
59
+
60
+
61
+ def _append(row):
62
+ try:
63
+ fp = _file(row.get("submitted_at") and
64
+ time.strftime("%Y-%m-%d", time.localtime(float(row["submitted_at"]))))
65
+ fp.parent.mkdir(parents=True, exist_ok=True)
66
+ with fp.open("a", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
68
+ handle.flush()
69
+ except Exception:
70
+ # Observability must not turn a remote write into a local failure.
71
+ pass
72
+
73
+
74
+ def _request_hash(request) -> str:
75
+ raw = json.dumps(request or {}, ensure_ascii=False, sort_keys=True,
76
+ separators=(",", ":"), default=str)
77
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
78
+
79
+
80
+ def _operation_id(target: str) -> str:
81
+ stamp = time.strftime("%Y%m%d-%H%M%S")
82
+ return "op-%s-%s-%s" % (stamp, secrets.token_hex(4),
83
+ hashlib.sha256(str(target).encode()).hexdigest()[:6])
84
+
85
+
86
+ def _safe_meta(metadata):
87
+ if not isinstance(metadata, dict):
88
+ return {}
89
+ out = {}
90
+ for key, value in metadata.items():
91
+ name = str(key)[:40]
92
+ if any(token in name.lower() for token in ("key", "token", "password", "secret")):
93
+ continue
94
+ if isinstance(value, (str, int, float, bool)) or value is None:
95
+ out[name] = str(value)[:300] if isinstance(value, str) else value
96
+ return out
97
+
98
+
99
+ def begin(target, request=None, *, task_id="", run_id="", metadata=None):
100
+ operation_id = _operation_id(target)
101
+ now = time.time()
102
+ row = {
103
+ "operation_id": operation_id,
104
+ "target": str(target or "")[:160],
105
+ "request_hash": _request_hash(request),
106
+ "submitted_at": now,
107
+ "status": "pending",
108
+ "remote_receipt": "",
109
+ "error": "",
110
+ "last_reconciled_at": None,
111
+ "task_id": str(task_id or "")[:80],
112
+ "run_id": str(run_id or "")[:80],
113
+ "metadata": _safe_meta(metadata),
114
+ }
115
+ with LOCK:
116
+ _append(row)
117
+ return operation_id
118
+
119
+
120
+ def update(operation_id, status, *, remote_receipt="", error="",
121
+ metadata=None, reconciled=False):
122
+ operation_id = str(operation_id or "").strip()
123
+ status = str(status or "").strip().lower()
124
+ if not operation_id or status not in STATUSES:
125
+ return False
126
+ with LOCK:
127
+ # Read and transition-check under the same lock as append. Two
128
+ # concurrent callbacks must not race a pending operation into
129
+ # conflicting terminal states.
130
+ current = get(operation_id)
131
+ if current is None:
132
+ return False
133
+ current_status = str(current.get("status") or "pending")
134
+ # External effects are monotonic: a confirmed write must never be
135
+ # downgraded by cleanup/error handling. Unknown/failed can only be
136
+ # promoted by an explicit remote reconciliation.
137
+ if current_status == "confirmed" and status != "confirmed":
138
+ return False
139
+ if current_status in ("unknown", "failed") and status == "confirmed" and not reconciled:
140
+ return False
141
+ if current_status in ("unknown", "failed") and status != current_status and not reconciled:
142
+ return False
143
+ if status == "confirmed" and not remote_receipt:
144
+ remote_receipt = current.get("remote_receipt") or ""
145
+ now = time.time()
146
+ row = {
147
+ "operation_id": operation_id,
148
+ "status": status,
149
+ "remote_receipt": str(remote_receipt or "")[:300],
150
+ "error": str(error or "")[:500],
151
+ "last_reconciled_at": now if reconciled else None,
152
+ "updated_at": now,
153
+ }
154
+ if metadata:
155
+ row["metadata"] = _safe_meta(metadata)
156
+ _append(row)
157
+ return True
158
+
159
+
160
+ def confirm(operation_id, remote_receipt="", *, metadata=None):
161
+ return update(operation_id, "confirmed", remote_receipt=remote_receipt,
162
+ metadata=metadata)
163
+
164
+
165
+ def fail(operation_id, error="", *, metadata=None):
166
+ return update(operation_id, "failed", error=error, metadata=metadata)
167
+
168
+
169
+ def mark_unknown(operation_id, error="", *, metadata=None):
170
+ return update(operation_id, "unknown", error=error, metadata=metadata)
171
+
172
+
173
+ def reconcile(operation_id, status, remote_receipt="", error="", metadata=None):
174
+ """Record a remote lookup result for an unknown operation."""
175
+ return update(operation_id, status, remote_receipt=remote_receipt, error=error,
176
+ metadata=metadata, reconciled=True)
177
+
178
+
179
+ def get(operation_id):
180
+ operation_id = str(operation_id or "")
181
+ latest = None
182
+ with LOCK:
183
+ for row in _read_all():
184
+ if row.get("operation_id") == operation_id:
185
+ latest = dict(latest or {}, **row)
186
+ return latest
187
+
188
+
189
+ def recent(*, target="", status="", limit=100):
190
+ latest = {}
191
+ with LOCK:
192
+ for row in _read_all():
193
+ if target and str(row.get("target") or "") != str(target):
194
+ continue
195
+ latest[row["operation_id"]] = dict(latest.get(row["operation_id"]) or {}, **row)
196
+ rows = list(latest.values())
197
+ if status:
198
+ rows = [r for r in rows if r.get("status") == status]
199
+ rows.sort(key=lambda r: float(r.get("updated_at") or r.get("submitted_at") or 0), reverse=True)
200
+ return rows[:max(1, min(1000, int(limit or 100)))]
201
+
202
+
203
+ def recover_pending(*, now=None, ttl_seconds=PENDING_TTL_SECONDS):
204
+ """Turn stale pending writes into unknown so restart never replays blindly."""
205
+ now = time.time() if now is None else float(now)
206
+ changed = 0
207
+ for row in recent(status="pending", limit=1000):
208
+ submitted = float(row.get("submitted_at") or 0)
209
+ if submitted and now - submitted > max(1, float(ttl_seconds)):
210
+ if mark_unknown(row["operation_id"], "进程重启后未收到远端回执,请先对账"):
211
+ changed += 1
212
+ return changed
213
+
214
+
215
+ def status_for_exception(error) -> str:
216
+ text = str(error or "").lower()
217
+ if isinstance(error, (TimeoutError, ConnectionError, urllib.error.URLError)) or any(x in text for x in
218
+ ("timeout", "timed out", "超时", "connection", "connect", "connection reset",
219
+ "断开", "断连", "连不上", "网络", "network", "unreachable", "disconnect")):
220
+ return "unknown"
221
+ return "failed"
222
+
223
+
224
+ def finish_exception(operation_id, error):
225
+ status = status_for_exception(error)
226
+ if status == "unknown":
227
+ return mark_unknown(operation_id, str(error))
228
+ return fail(operation_id, str(error))
@@ -1,11 +1,14 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
3
 
4
- 数据源(双源聚合,任一失败不影响另一源):
5
- - 七猫:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB)
6
- - 番茄 Web 版:fanqienovel.com/rank(公开可抓,题材分类+书名在
7
- 中文 a 标签里;App 端接口有 SecuritySign/X-Argus 签名——那才是难路,
8
- Web 版榜单页不需要签名,别走弯路)
4
+ 数据源(四源聚合,任一失败不影响其余源;2026-09-24 实测全 200):
5
+ - 七猫:www.qimao.com/paihang/(106KB)
6
+ - 番茄 Web 版:fanqienovel.com/rank(题材分类+书名在中文 a 标签里;
7
+ App 端接口有 SecuritySign/X-Argus 签名——那才是难路,Web 版榜单页
8
+ 不需要签名,别走弯路)
9
+ - 起点移动版:m.qidian.com/rank(106KB,直接出书名;主站 www.qidian.com
10
+ 被 WAF 拦 202/209B,移动页不需要签名)
11
+ - 纵横:www.zongheng.com/rank(65KB,书名+统计标签混排,靠噪音词过滤)
9
12
 
10
13
  下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
11
14
  宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
@@ -19,16 +22,24 @@ log = logging.getLogger(__name__)
19
22
 
20
23
  QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
21
24
  FANQIE_RANK_URL = "https://fanqienovel.com/rank"
25
+ QIDIAN_M_RANK_URL = "https://m.qidian.com/rank/"
26
+ ZONGHENG_RANK_URL = "https://www.zongheng.com/rank/"
22
27
  _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
23
28
  "(KHTML, like Gecko) Chrome/126 Safari/537.36")
24
29
 
25
- # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索等
30
+ # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索、
31
+ # 榜单 tab 与统计标签(起点移动版 tab 名、纵横月票/人气/点击等)
26
32
  _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
27
33
  "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
28
34
  "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
29
35
  "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
30
36
  "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
31
- "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于"}
37
+ "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于",
38
+ "月票榜", "阅读榜", "书友榜", "推荐榜", "签约榜", "新人榜",
39
+ "畅销榜", "大家都在搜", "男生小说排行榜", "女生小说排行榜",
40
+ "男频阅读榜", "女频阅读榜", "男频新书榜", "番茄小说",
41
+ "登录后获得更多特色功能", "安装起点读书客户端", "看更多正版好书",
42
+ "月票", "人气", "点击", "万字", "推荐", "收藏", "鲜花", "打赏", "推荐票"}
32
43
 
33
44
  _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
34
45
 
@@ -70,39 +81,57 @@ def fetch_fanqie_rank(limit=30):
70
81
  return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
71
82
 
72
83
 
84
+ def fetch_qidian_rank(limit=30):
85
+ """起点移动版榜单:返回元素列表,抓取失败返回 []。"""
86
+ return _extract_cjk(_curl_text(QIDIAN_M_RANK_URL), limit)
87
+
88
+
89
+ def fetch_zongheng_rank(limit=30):
90
+ """纵横排行榜:返回元素列表,抓取失败返回 []。"""
91
+ return _extract_cjk(_curl_text(ZONGHENG_RANK_URL), limit)
92
+
93
+
94
+ # 源清单:顺序即素材拼接顺序(七猫/番茄为元老源保持在前)。
95
+ # 存函数名、调用期 globals() 解析——mock.patch 换模块属性才能生效
96
+ # (存函数引用会把定义期原函数钉死,测试会穿透打真网络)。
97
+ _SOURCES = (
98
+ ("七猫排行榜素材", "fetch_qimao_rank"),
99
+ ("番茄排行榜素材", "fetch_fanqie_rank"),
100
+ ("起点排行榜素材", "fetch_qidian_rank"),
101
+ ("纵横排行榜素材", "fetch_zongheng_rank"),
102
+ )
103
+
104
+
73
105
  def fetch_rank_items(limit=40):
74
- """双源聚合:七猫+番茄各抓一份(去重合并,七猫在前)。
106
+ """四源聚合:各抓一份(跨源去重合并,按源清单顺序)。
75
107
 
76
- 两源都失败才返回 []——单源挂了另一源仍可用,扫榜永不因单点挡任务。"""
77
- qm = fetch_qimao_rank(limit)
78
- fq = fetch_fanqie_rank(limit)
79
- merged, seen = [], set(qm)
80
- for t in fq:
81
- if t not in seen:
82
- seen.add(t)
83
- merged.append(t)
84
- return qm + merged
108
+ 全部失败才返回 []——单源挂了其余源仍可用,扫榜永不因单点挡任务。"""
109
+ merged, seen = [], set()
110
+ for _label, fname in _SOURCES:
111
+ for t in globals()[fname](limit):
112
+ if t not in seen:
113
+ seen.add(t)
114
+ merged.append(t)
115
+ return merged
85
116
 
86
117
 
87
118
  def rank_scan_prompt(goal):
88
- """组装扫榜选材分析提示词。双源都空返回 None(调用方回落直连提示词)。"""
89
- qm = fetch_qimao_rank(30)
90
- fq = fetch_fanqie_rank(30)
91
- qm = [t for t in qm if t not in fq] # 双源重合的只留七猫份
92
- if not qm and not fq:
119
+ """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。"""
120
+ blocks, seen = [], set()
121
+ for label, fname in _SOURCES:
122
+ items = [t for t in globals()[fname](30) if t not in seen]
123
+ seen.update(items)
124
+ if items:
125
+ blocks.append("### %s\n%s" % (label, "、".join(items)))
126
+ if not blocks:
93
127
  return None
94
- blocks = []
95
- if qm:
96
- blocks.append("### 七猫排行榜素材\n" + "、".join(qm))
97
- if fq:
98
- blocks.append("### 番茄排行榜素材\n" + "、".join(fq))
99
128
  material = "\n\n".join(blocks)
100
129
  return (
101
- "你是网文选题分析师。以下是刚刚抓取的两个平台排行榜页面的书目与分类素材:\n\n"
130
+ "你是网文选题分析师。以下是刚刚抓取的各平台排行榜页面的书目与分类素材:\n\n"
102
131
  "## 榜单元素材\n%s\n\n"
103
132
  "## 用户想写的方向\n%s\n\n"
104
133
  "## 你的产出(Markdown 报告)\n"
105
- "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——双平台都热说明是真风口)\n"
134
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
106
135
  "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
107
136
  "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
108
137
  "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"