Skip to content

Commit 100ce17

Browse files
committed
fix(review-feedback-1265): address latest review comments
1 parent 9dbd23a commit 100ce17

6 files changed

Lines changed: 232 additions & 18 deletions

File tree

data_provider/akshare_fetcher.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,8 +1961,9 @@ def get_limit_up_pool(
19611961
if col in df.columns:
19621962
df[col] = pd.to_numeric(df[col], errors='coerce')
19631963
if '首次封板时间' in df.columns:
1964-
df['首次封板时间'] = df['首次封板时间'].astype(str)
1965-
sort_cols = [col for col in ('连板数', '首次封板时间') if col in df.columns]
1964+
df['首次封板时间'] = df['首次封板时间'].map(self._normalize_limit_time_value)
1965+
df['_首次封板时间排序'] = df['首次封板时间'].where(df['首次封板时间'] != '', '999999')
1966+
sort_cols = [col for col in ('连板数', '_首次封板时间排序') if col in df.columns]
19661967
if sort_cols:
19671968
ascending = [False if col == '连板数' else True for col in sort_cols]
19681969
df = df.sort_values(sort_cols, ascending=ascending)
@@ -1989,6 +1990,35 @@ def get_limit_up_pool(
19891990
logger.warning(f"[Akshare] 获取涨停池失败: {e}")
19901991
return None
19911992

1993+
@staticmethod
1994+
def _normalize_limit_time_value(value: Any) -> str:
1995+
"""Normalize AkShare HHMMSS-like seal time values to zero-padded HHMMSS."""
1996+
try:
1997+
if pd.isna(value):
1998+
return ""
1999+
except TypeError:
2000+
pass
2001+
2002+
text = str(value).strip()
2003+
if not text or text.lower() in {"nan", "nat", "none", "null", "-", "--"}:
2004+
return ""
2005+
2006+
if ":" in text:
2007+
parts = text.split(":")
2008+
try:
2009+
hour = int(parts[0])
2010+
minute = int(parts[1]) if len(parts) > 1 else 0
2011+
second = int(parts[2]) if len(parts) > 2 else 0
2012+
return f"{hour:02d}{minute:02d}{second:02d}"
2013+
except (TypeError, ValueError):
2014+
return text
2015+
2016+
try:
2017+
return f"{int(float(text)):06d}"
2018+
except (TypeError, ValueError):
2019+
digits = "".join(ch for ch in text if ch.isdigit())
2020+
return digits.zfill(6) if digits else text
2021+
19922022
@staticmethod
19932023
def _safe_float(value: Any) -> Optional[float]:
19942024
try:

src/formatters.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,43 @@ def _bytes(s: str) -> int:
265265
return len(s.encode('utf-8'))
266266

267267

268+
def _is_escaped_pipe(value: str, index: int) -> bool:
269+
"""Return whether value[index] is escaped by an odd number of backslashes."""
270+
slash_count = 0
271+
pos = index - 1
272+
while pos >= 0 and value[pos] == "\\":
273+
slash_count += 1
274+
pos -= 1
275+
return slash_count % 2 == 1
276+
277+
278+
def _parse_markdown_table_row(row: str) -> List[str]:
279+
"""Split a Markdown table row while preserving escaped literal pipes."""
280+
value = row.strip()
281+
if value.startswith("|"):
282+
value = value[1:]
283+
if value.endswith("|") and not _is_escaped_pipe(value, len(value) - 1):
284+
value = value[:-1]
285+
286+
cells: List[str] = []
287+
current: List[str] = []
288+
index = 0
289+
while index < len(value):
290+
char = value[index]
291+
if char == "\\" and index + 1 < len(value) and value[index + 1] == "|":
292+
current.append("|")
293+
index += 2
294+
continue
295+
if char == "|":
296+
cells.append("".join(current).strip())
297+
current = []
298+
else:
299+
current.append(char)
300+
index += 1
301+
cells.append("".join(current).strip())
302+
return cells
303+
304+
268305
def _chunk_by_max_bytes(content: str, max_bytes: int) -> List[str]:
269306
if _bytes(content) <= max_bytes:
270307
return [content]
@@ -421,7 +458,7 @@ def format_feishu_markdown(content: str) -> str:
421458
>>> print(formatted)
422459
**标题**
423460
> 引用
424-
```text
461+
```
425462
列1 列2
426463
值1 值2
427464
```
@@ -458,8 +495,7 @@ def _flush_table_rows(buffer: List[str], output: List[str]) -> None:
458495

459496
def _parse_row(row: str) -> List[str]:
460497
"""解析表格行,提取单元格"""
461-
cells = [c.strip() for c in row.strip().strip('|').split('|')]
462-
return cells
498+
return _parse_markdown_table_row(row)
463499

464500
rows = []
465501
for raw in buffer:
@@ -545,7 +581,7 @@ def _is_table_separator(row: str) -> bool:
545581
return bool(re.match(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$', row))
546582

547583
def _parse_table_row(row: str) -> List[str]:
548-
return [cell.strip() for cell in row.strip().strip("|").split("|")]
584+
return _parse_markdown_table_row(row)
549585

550586
def _text(content_value: str, tag: str = "lark_md") -> Dict[str, str]:
551587
return {"tag": tag, "content": content_value}

src/notification_sender/feishu_sender.py

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,11 @@ def _send_feishu_message(self, content: str, *, timeout_seconds: Optional[float]
190190
prepared_content = self._apply_keyword_prefix(content)
191191
security_fields = self._build_security_fields()
192192

193-
def _post_payload(payload: Dict[str, Any]) -> bool:
193+
def _post_payload(payload: Dict[str, Any], payload_text: str = prepared_content) -> bool:
194194
request_payload = dict(payload)
195195
request_payload.update(security_fields)
196196
logger.debug(f"飞书请求 URL: {self._feishu_url}")
197-
logger.debug(f"飞书请求 payload 长度: {len(prepared_content)} 字符")
197+
logger.debug(f"飞书请求 payload 长度: {len(payload_text)} 字符")
198198

199199
response = requests.post(
200200
self._feishu_url,
@@ -223,6 +223,63 @@ def _post_payload(payload: Dict[str, Any]) -> bool:
223223
logger.error(f"响应内容: {response.text}")
224224
return False
225225

226+
def _post_text_payload(text_content: str) -> bool:
227+
prepared_text = self._apply_keyword_prefix(text_content)
228+
text_payload = {
229+
"msg_type": "text",
230+
"content": {
231+
"text": prepared_text
232+
}
233+
}
234+
return _post_payload(text_payload, prepared_text)
235+
236+
def _send_fallback_text(text_content: str) -> bool:
237+
max_bytes = self._feishu_max_bytes
238+
keyword_overhead = len(self._get_keyword_prefix().encode('utf-8'))
239+
effective_max_bytes = max_bytes - keyword_overhead
240+
241+
if effective_max_bytes <= 0:
242+
logger.error("飞书关键词过长,超过单条消息允许的最大字节数,无法发送")
243+
return False
244+
245+
text_bytes = len(text_content.encode('utf-8')) + keyword_overhead
246+
if text_bytes <= max_bytes:
247+
return _post_text_payload(text_content)
248+
249+
min_chunk_bytes = MIN_MAX_BYTES + PAGE_MARKER_SAFE_BYTES
250+
if effective_max_bytes < min_chunk_bytes:
251+
logger.error(
252+
"飞书回退文本分片预算(%s字节)不足以安全分页发送,至少需要 %s 字节",
253+
effective_max_bytes,
254+
min_chunk_bytes,
255+
)
256+
return False
257+
258+
try:
259+
chunks = chunk_content_by_max_bytes(
260+
text_content,
261+
effective_max_bytes,
262+
add_page_marker=True,
263+
)
264+
except ValueError as e:
265+
logger.error("飞书回退文本分片失败: %s", e)
266+
return False
267+
268+
success_count = 0
269+
total_chunks = len(chunks)
270+
logger.info("飞书回退文本超长(%s字节/%s字符),将分批发送:共 %s 批", text_bytes, len(text_content), total_chunks)
271+
for i, chunk in enumerate(chunks):
272+
if _post_text_payload(chunk):
273+
success_count += 1
274+
logger.info("飞书回退文本第 %s/%s 批发送成功", i + 1, total_chunks)
275+
else:
276+
logger.error("飞书回退文本第 %s/%s 批发送失败", i + 1, total_chunks)
277+
278+
if i < total_chunks - 1:
279+
time.sleep(1)
280+
281+
return success_count == total_chunks
282+
226283
# 1) 优先使用结构化交互卡片。飞书的 lark_md 不是完整 Markdown,
227284
# 表格/代码块/引用需要拆成卡片元素才会真正渲染。
228285
card_elements = build_feishu_card_elements(prepared_content)
@@ -243,12 +300,6 @@ def _post_payload(payload: Dict[str, Any]) -> bool:
243300
if _post_payload(card_payload):
244301
return True
245302

246-
# 2) 回退为普通文本消息
247-
text_payload = {
248-
"msg_type": "text",
249-
"content": {
250-
"text": format_feishu_markdown(prepared_content)
251-
}
252-
}
253-
254-
return _post_payload(text_payload)
303+
# 2) 回退为普通文本消息。回退格式可能比原始 Markdown 更长,
304+
# 发送前按真实回退文本重新校验并必要时分片。
305+
return _send_fallback_text(format_feishu_markdown(content))

tests/test_akshare_realtime_logging.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import logging
2+
import sys
3+
from types import SimpleNamespace
24

5+
import pandas as pd
36
import pytest
47
import requests
58

@@ -150,7 +153,7 @@ def test_tencent_realtime_success_logs_endpoint(caplog, monkeypatch, akshare_fet
150153
assert "[实时行情-腾讯] 601006 大秦铁路:" in caplog.text
151154

152155

153-
def test_hot_stocks_falls_back_to_eastmoney(monkeypatch, akshare_fetcher):
156+
def test_hot_stocks_uses_eastmoney_hot_ranking_when_available(monkeypatch, akshare_fetcher):
154157
monkeypatch.setattr(
155158
akshare_fetcher,
156159
"_get_eastmoney_hot_stocks",
@@ -170,3 +173,62 @@ def test_hot_stocks_falls_back_to_eastmoney(monkeypatch, akshare_fetcher):
170173

171174
assert result[0]["source"] == "东方财富人气榜"
172175
assert result[0]["name"] == "中国长城"
176+
177+
178+
def test_limit_up_pool_zero_pads_first_seal_times_before_sorting(monkeypatch, akshare_fetcher):
179+
df = pd.DataFrame(
180+
[
181+
{
182+
"代码": "000002",
183+
"名称": "午后股",
184+
"涨跌幅": 10.0,
185+
"最新价": 12.3,
186+
"成交额": 1,
187+
"换手率": 2,
188+
"封板资金": 3,
189+
"首次封板时间": 141354,
190+
"最后封板时间": 141500,
191+
"炸板次数": 0,
192+
"涨停统计": "1/1",
193+
"连板数": 1,
194+
"所属行业": "地产",
195+
},
196+
{
197+
"代码": "000001",
198+
"名称": "竞价股",
199+
"涨跌幅": 10.0,
200+
"最新价": 10.0,
201+
"成交额": 1,
202+
"换手率": 2,
203+
"封板资金": 3,
204+
"首次封板时间": 92500,
205+
"最后封板时间": 93000,
206+
"炸板次数": 0,
207+
"涨停统计": "1/1",
208+
"连板数": 1,
209+
"所属行业": "计算机",
210+
},
211+
{
212+
"代码": "000003",
213+
"名称": "早盘股",
214+
"涨跌幅": 10.0,
215+
"最新价": 11.0,
216+
"成交额": 1,
217+
"换手率": 2,
218+
"封板资金": 3,
219+
"首次封板时间": 101500,
220+
"最后封板时间": 102000,
221+
"炸板次数": 0,
222+
"涨停统计": "1/1",
223+
"连板数": 1,
224+
"所属行业": "电子",
225+
},
226+
]
227+
)
228+
fake_akshare = SimpleNamespace(stock_zt_pool_em=lambda date: df)
229+
monkeypatch.setitem(sys.modules, "akshare", fake_akshare)
230+
231+
result = akshare_fetcher.get_limit_up_pool(date="20260511", n=3)
232+
233+
assert [row["code"] for row in result] == ["000001", "000003", "000002"]
234+
assert result[0]["first_limit_time"] == "092500"

tests/test_formatters.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,16 @@ def test_card_elements_render_tables_as_columns(self):
220220
self.assertIn('"tag": "note"', serialized)
221221
self.assertNotIn("```", serialized)
222222
self.assertNotIn("| 指标 |", serialized)
223+
224+
def test_card_table_preserves_escaped_literal_pipes(self):
225+
content = """| 类型 | 标题 | 备注 |
226+
| --- | --- | --- |
227+
| 新闻 | AI \\| chips | 后续 |
228+
"""
229+
elements = build_feishu_card_elements(content)
230+
column_set = next(element for element in elements if element["tag"] == "column_set")
231+
serialized = json.dumps(elements, ensure_ascii=False)
232+
233+
self.assertEqual(len(column_set["columns"]), 3)
234+
self.assertIn("AI | chips", serialized)
235+
self.assertIn("后续", serialized)

tests/test_notification_sender.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,28 @@ def test_send_error_response_returns_false(self, mock_post):
236236
self.assertFalse(result)
237237
self.assertEqual(mock_post.call_count, 2)
238238

239+
@mock.patch("src.notification_sender.feishu_sender.time.sleep", return_value=None)
240+
@mock.patch("src.notification_sender.feishu_sender.format_feishu_markdown", return_value="F" * 180)
241+
@mock.patch("src.notification_sender.feishu_sender.requests.post")
242+
def test_card_failure_chunks_oversized_fallback_text(self, mock_post, mock_format, _mock_sleep):
243+
mock_post.side_effect = (
244+
[_response(200, {"code": 19024, "msg": "card failed"})]
245+
+ [_response(200, {"code": 0}) for _ in range(10)]
246+
)
247+
cfg = _config(feishu_webhook_url="https://feishu.example/hook", feishu_max_bytes=100)
248+
sender = FeishuSender(cfg)
249+
250+
result = sender.send_to_feishu("short")
251+
252+
self.assertTrue(result)
253+
mock_format.assert_called_once_with("short")
254+
self.assertGreater(mock_post.call_count, 2)
255+
self.assertEqual(mock_post.call_args_list[0].kwargs["json"]["msg_type"], "interactive")
256+
for call in mock_post.call_args_list[1:]:
257+
payload = call.kwargs["json"]
258+
self.assertEqual(payload["msg_type"], "text")
259+
self.assertLessEqual(len(payload["content"]["text"].encode("utf-8")), 100)
260+
239261
@mock.patch("src.notification_sender.feishu_sender.requests.post")
240262
def test_send_with_keyword_that_leaves_too_little_chunk_budget_returns_false(self, mock_post):
241263
cfg = _config(

0 commit comments

Comments
 (0)