项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

598 行
19 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import datetime
import re
from typing import Any, Iterable, Optional
import requests
_CITY_ALIASES: dict[str, list[str]] = {
"北京": ["北京", "北京市", "beijing"],
"上海": ["上海", "上海市", "shanghai"],
"广州": ["广州", "广州市", "guangzhou"],
"深圳": ["深圳", "深圳市", "shenzhen"],
"杭州": ["杭州", "杭州市", "hangzhou"],
"成都": ["成都", "成都市", "chengdu"],
"三亚": ["三亚", "三亚市", "sanya"],
"京都": ["京都", "kyoto"],
}
def clean_text(text: str) -> str:
"""统一自 crossapp/tasks.py、crossapp2/tasks.py。"""
if not text:
return ""
# 去掉非单词字符、非中文(包括空格)
# \u4fdd\u7559 \w + CJK + \u5e38\u7528 emoji \u5757\uff1b\u5220\u9664\u6807\u70b9/\u5168\u534a\u89d2\u7b26\u53f7/\u7a7a\u683c\u3002
# \u4fdd\u7559 emoji \u662f\u4e3a\u4e86\u8ba9"\u590d\u5236 title"\u7c7b\u4efb\u52a1\u80fd\u5224\u5b9a Agent \u662f\u5426\u5b8c\u6574\u590d\u5236\u5e26 emoji \u7684\u6807\u9898
# (emoji \u662f\u6570\u636e\u8bed\u4e49\u7684\u4e00\u90e8\u5206\uff0c\u4e0d\u5e94\u88ab norm \u541e\u6389)\u3002
# U+1F300-1FAFF: \u4e3b emoji \u533a (Pictographs / Emoticons / Symbols Extended-A/B)
# U+2600-27BF: Misc Symbols + Dingbats (\u2600 \u2728 \u2713 \u2b50 etc.)
text = re.sub(
r"[^\w\u4e00-\u9fff\U0001F300-\U0001FAFF\u2600-\u27bf]",
"",
text,
).replace("_", "").strip()
return text
def norm(s: str) -> str:
"""统一自 crossapp/tasks.py、crossapp2/tasks.py、spotify/tasks.py。"""
return clean_text(str(s or "")).lower()
# ── Traditional → Simplified Chinese mapping (opencc) ─────────────────
_t2s_converter = None
def _get_t2s():
global _t2s_converter
if _t2s_converter is None:
try:
import opencc
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"Missing dependency 'opencc-python-reimplemented'. Install:\n"
" pip install opencc-python-reimplemented"
) from e
_t2s_converter = opencc.OpenCC('t2s')
return _t2s_converter
def to_simplified(s: str) -> str:
"""Convert traditional Chinese to simplified via opencc t2s."""
if not s:
return s
return _get_t2s().convert(s)
def to_float(v: Any) -> Optional[float]:
"""统一自 weather/tasks.py、crossapp/tasks.py、crossapp2/tasks.py。"""
try:
return float(str(v).strip())
except Exception:
return None
def extract_numbers(text: str) -> list[float]:
"""统一自 crossapp3/tasks.py、spe_tasks/tasks.py、ebay/tasks.py。"""
# 仅把 ASCII 单词字符视作数字边界,允许“余额是100.23元”这类中文包裹的数值被识别。
nums = re.findall(
r"(?<![0-9A-Za-z_.])(-?(?:\d{1,3}(?:[,,]\d{3})+|\d+)(?:\.\d+)?)(?![0-9A-Za-z_.])",
text or "",
)
out: list[float] = []
for n in nums:
try:
out.append(float(n.replace(",", "").replace(",", "")))
except Exception:
continue
return out
def city_aliases(city_name: str) -> list[str]:
"""返回城市名的多种匹配变体(包含本身)。"""
key = city_name.strip()
if key in _CITY_ALIASES:
return _CITY_ALIASES[key]
lowered = key.lower()
for canonical, aliases in _CITY_ALIASES.items():
if lowered in aliases or lowered == canonical.lower():
return aliases
return [key]
def amount_labels(value: float) -> list[str]:
"""生成金额的多种文本表示,用于子串匹配。"""
rounded = round(float(value), 2)
labels = {
f"{rounded:.2f}",
f"{rounded:.1f}",
f"{rounded:g}",
str(int(rounded)) if float(rounded).is_integer() else f"{rounded:g}",
}
return [label for label in labels if label]
_CN_DIGITS = "零一二三四五六七八九"
_CN_SMALL_UNITS = ["", "十", "百", "千"]
_CN_GROUP_UNITS = ["", "万", "亿", "兆"]
def _int_to_chinese_group(value: int) -> str:
if value == 0:
return "零"
parts: list[str] = []
zero_pending = False
digits = f"{value:04d}"
for index, ch in enumerate(digits):
digit = int(ch)
unit_index = 3 - index
if digit == 0:
if parts:
zero_pending = True
continue
if zero_pending:
parts.append("零")
zero_pending = False
parts.append(_CN_DIGITS[digit])
parts.append(_CN_SMALL_UNITS[unit_index])
return "".join(parts) or "零"
def int_to_chinese(value: int) -> str:
"""把整数转换为中文数字,如 33 -> 三十三,36650 -> 三万六千六百五十。"""
number = int(value)
if number == 0:
return "零"
if number < 0:
return f"负{int_to_chinese(-number)}"
groups: list[int] = []
while number > 0:
groups.append(number % 10000)
number //= 10000
parts: list[str] = []
zero_between = False
for group_index in range(len(groups) - 1, -1, -1):
group = groups[group_index]
if group == 0:
zero_between = bool(parts)
continue
if parts and (zero_between or group < 1000):
parts.append("零")
parts.append(_int_to_chinese_group(group))
parts.append(_CN_GROUP_UNITS[group_index])
zero_between = False
result = "".join(parts)
if result.startswith("一十"):
return result[1:]
return result
def integer_labels(value: int) -> list[str]:
"""生成整数的多种文本表示,用于 judge 匹配。"""
number = int(value)
labels = {
str(number),
f"{number:,}",
f"{number:,}".replace(",", ","),
int_to_chinese(number),
}
return [label for label in labels if label]
def count_titles_in_text(text: str, titles: Iterable[str]) -> int:
"""统计 titles 中有多少个标题出现在 text 中。"""
return sum(1 for title in titles if title and title in text)
def parse_distance_to_meters(distance_text: Any) -> Optional[float]:
"""统一自 crossapp/tasks.py、crossapp3/tasks.py。"""
s = str(distance_text or "").strip().lower()
if not s:
return None
m = re.search(r"(\d+(?:\.\d+)?)", s)
if not m:
return None
try:
v = float(m.group(1))
except Exception:
return None
if "mi" in s or "mile" in s:
return v * 1609.344
if "km" in s or "公里" in s:
return v * 1000.0
if "m" in s or "米" in s:
return v
return v
def parse_duration_to_minutes(duration_text: Any) -> Optional[float]:
"""统一自 crossapp/tasks.py。"""
s = str(duration_text or "").strip().lower()
if not s:
return None
nums = re.findall(r"(\d+(?:\.\d+)?)", s)
if not nums:
return None
vals: list[float] = []
for x in nums:
try:
vals.append(float(x))
except Exception:
continue
if not vals:
return None
if "小时" in s or "hr" in s or "hour" in s:
if len(vals) >= 2 and ("分钟" in s or "min" in s):
return vals[0] * 60.0 + vals[1]
return vals[0] * 60.0
return vals[0]
def parse_duration_to_seconds(duration_text: Any) -> Optional[int]:
"""Parse a human-readable duration string to total seconds.
Recognises Chinese (小时/分钟/秒) and English (h/hr/min/sec/…) units.
Returns None when the text is empty or contains no recognisable units.
"""
text = str(duration_text or "").strip()
if not text:
return None
total = 0
for pattern, multiplier in (
(r"(\d+(?:\.\d+)?)\s*(?:小时|hr|hours?|h)", 3600),
(r"(\d+(?:\.\d+)?)\s*(?:分钟|mins?|minutes?|m)", 60),
(r"(\d+(?:\.\d+)?)\s*(?:秒|secs?|seconds?|s)", 1),
):
m = re.search(pattern, text, re.I)
if m:
total += int(float(m.group(1)) * multiplier)
return total or None
def now_ms(os_state: dict) -> int:
"""从 os_state 中提取模拟时间戳(毫秒)。禁止 fallback 到本地时间。"""
t = (os_state or {}).get("time")
if isinstance(t, (int, float)):
return int(t)
if isinstance(t, dict):
ts = t.get("timestamp")
if isinstance(ts, (int, float)):
return int(ts)
raise ValueError(
"os_state 中缺少 time 或 time.timestamp 字段,无法获取模拟时间。"
" 请确保 bench_env 正确读取了 __SIM__.getState() 的 os.time。"
)
def sim_today(os_state: dict) -> datetime.date:
"""从 os_state 中提取模拟日期。禁止 fallback 到本地时间。"""
return datetime.date.fromtimestamp(now_ms(os_state) / 1000.0)
def sim_datetime(os_state: dict) -> datetime.datetime:
"""从 os_state 中提取模拟 datetime。禁止 fallback 到本地时间。"""
return datetime.datetime.fromtimestamp(now_ms(os_state) / 1000.0)
def today_ymd(os_state: dict) -> str:
"""统一自 spe_tasks/tasks.py。"""
d = sim_today(os_state)
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
def tomorrow_ymd(os_state: dict) -> str:
"""统一自 spe_tasks/tasks.py。"""
d = sim_today(os_state) + datetime.timedelta(days=1)
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
def default_tomorrow() -> str:
"""Callable default: 基于真实时间的明天 ISO 日期,用于 parameters default。"""
return (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
def sample_future_date(env_state: dict, rng) -> str:
"""Callable sampler: 基于模拟时间采样未来 1-13 天的 ISO 日期。"""
base = sim_today((env_state.get("os") or {}))
offset = rng.choice(range(1, 14))
return (base + datetime.timedelta(days=offset)).isoformat()
_WEEKDAY_ZH = ["一", "二", "三", "四", "五", "六", "日"]
def format_date_natural(value: str, env_state: dict) -> str:
"""Display formatter: 明天/后天/这周X/下周X/X月X号(基于模拟时间)。"""
parts = str(value).split("-")
if len(parts) != 3:
return str(value)
target = datetime.date(int(parts[0]), int(parts[1]), int(parts[2]))
today = sim_today(env_state.get("os") or {})
delta = (target - today).days
if delta == 1:
return "明天"
if delta == 2:
return "后天"
target_wd = target.weekday()
today_wd = today.weekday()
days_to_next_monday = 7 - today_wd
if 0 < delta <= (6 - today_wd):
return f"这周{_WEEKDAY_ZH[target_wd]}"
if days_to_next_monday <= delta <= days_to_next_monday + 6:
return f"下周{_WEEKDAY_ZH[target_wd]}"
return f"{target.month}{target.day}号"
def normalize_price(value: float) -> int | float:
"""统一金额显示:保留两位小数,整数则返回 int。"""
rounded = round(float(value), 2)
return int(rounded) if rounded == int(rounded) else rounded
def day_bounds_ms(d: datetime.date) -> tuple[int, int]:
"""统一自 crossapp3/tasks.py。"""
start = datetime.datetime(d.year, d.month, d.day, 0, 0, 0)
end = start + datetime.timedelta(days=1)
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
def day_time_ms(d: datetime.date, time_text: str = "00:00") -> int:
"""返回指定模拟本地日期和 HH:MM 时间对应的毫秒时间戳。"""
hour, minute = [int(part) for part in time_text.split(":")]
dt = datetime.datetime(d.year, d.month, d.day, hour, minute)
return int(dt.timestamp() * 1000)
def opened_app_since_init(os_state: dict, os_init_state: dict, app_id: str) -> bool:
"""Check whether *app_id* was opened after task start."""
current_running = os_state["runningApps"]
initial_running = os_init_state["runningApps"]
return app_id in current_running and app_id not in initial_running
def norm_city_key(s: Any) -> str:
"""统一自 weather/tasks.py、crossapp/tasks.py、crossapp2/tasks.py。"""
s = str(s or "").strip().lower()
s = re.sub(r"\s+", "", s)
if s.endswith("市"):
s = s[:-1]
return s
def parse_date(value: str) -> datetime.date | None:
"""统一自 crossapp3/tasks.py。"""
text = str(value or "").strip()
if not text:
return None
try:
return datetime.date.fromisoformat(text)
except Exception:
pass
matched = re.search(r"(\d{4})\D+(\d{1,2})\D+(\d{1,2})", text)
if not matched:
return None
try:
return datetime.date(
int(matched.group(1)),
int(matched.group(2)),
int(matched.group(3)),
)
except Exception:
return None
_RELATIVE_DAY_NAMES = {0: "今天", 1: "明天", 2: "后天", 3: "大后天"}
def date_match_labels(
date_value: str,
os_state: dict | None = None,
) -> list[str]:
"""Generate all plausible textual representations of a date for fuzzy matching.
Returns labels in order: ISO, M月D日, M月D号, D日, D号, 周X, 星期X,
plus relative labels (今天/明天/后天/大后天/这周X/下周X) when *os_state* is provided.
"""
parsed = parse_date(date_value)
if parsed is None:
return [str(date_value)]
wd_zh = _WEEKDAY_ZH[parsed.weekday()]
labels = [
parsed.isoformat(),
f"{parsed.month}{parsed.day}日",
f"{parsed.month}{parsed.day}号",
f"{parsed.day}日",
f"{parsed.day}号",
f"周{wd_zh}",
f"星期{wd_zh}",
]
if os_state is not None:
try:
today = sim_today(os_state)
delta = (parsed - today).days
rel = _RELATIVE_DAY_NAMES.get(delta)
if rel is not None:
labels.append(rel)
# 这周X / 下周X(与 format_date_natural 逻辑对齐)
if delta > 0:
today_wd = today.weekday()
if delta <= (6 - today_wd):
labels.append(f"这周{wd_zh}")
else:
days_to_next_monday = 7 - today_wd
if days_to_next_monday <= delta <= days_to_next_monday + 6:
labels.append(f"下周{wd_zh}")
except ValueError:
pass
return labels
def format_fixed(value: float, decimal_places: int) -> str:
"""统一自 crossapp3/tasks.py。"""
decimal_places = max(0, min(10, int(decimal_places)))
return f"{value:.{decimal_places}f}"
def extract_urls(text: str) -> list[str]:
"""统一自 crossapp3/tasks.py。"""
return [
url.strip().rstrip(").,,。")
for url in re.findall(r"https?://\S+", text or "")
]
def basename(path: Any) -> str:
"""统一自 crossapp3/tasks.py。"""
text = str(path or "").strip()
if not text:
return ""
text = text.split("?", 1)[0].split("#", 1)[0].replace("\\", "/")
return text.rsplit("/", 1)[-1]
def strip_postal_suffix(text: str) -> str:
"""统一自 crossapp/tasks.py、crossapp2/tasks.py。"""
raw = str(text or "").strip()
matched = re.search(r"(邮政编码|邮编)\s*[:]?", raw)
if matched:
raw = raw[: matched.start()].strip()
# Strip Plus Code suffix (e.g. "275X+67P", "W9CW+7PQ")
raw = re.sub(r"\s*[A-Z0-9]{2,6}\+[A-Z0-9]{2,4}\s*$", "", raw)
return raw
def parse_hhmm(value: str) -> int | None:
"""统一自 spe_tasks/tasks.py。"""
matched = re.fullmatch(r"(\d{1,2}):(\d{2})", str(value or "").strip())
if not matched:
return None
hour = int(matched.group(1))
minute = int(matched.group(2))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return hour * 60 + minute
def format_hhmm(hour: int, minute: int) -> str:
"""格式化小时和分钟为 HH:MM 字符串。"""
return f"{int(hour):02d}:{int(minute):02d}"
def new_items_by_id(
current: list[dict[str, Any]], initial: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""统一自 spe_tasks/tasks.py。"""
initial_ids = {str((item or {}).get("id", "")) for item in (initial or [])}
result: list[dict[str, Any]] = []
for item in current or []:
item_id = str((item or {}).get("id", ""))
if item_id and item_id not in initial_ids:
result.append(item)
result.sort(key=lambda item: int((item or {}).get("timestamp", 0) or 0))
return result
def numbers_contain_all(
haystack: list[float], expected: list[float], tol: float = 0.02
) -> bool:
"""统一自 spe_tasks/tasks.py。"""
for value in expected:
if not any(abs(float(item) - float(value)) <= tol for item in haystack):
return False
return True
def subsequence_contains_numbers(
numbers: list[float], expected: list[float], tol: float = 0.02
) -> bool:
"""统一自 spe_tasks/tasks.py。"""
if not expected:
return True
index = 0
for number in numbers:
if abs(float(number) - float(expected[index])) <= tol:
index += 1
if index >= len(expected):
return True
return False
def has_close_number(text: str, expected: float, tol: float = 0.01) -> bool:
"""统一自 ebay/tasks.py。"""
for number in extract_numbers(text):
if abs(number - expected) <= tol:
return True
return False
def has_int(text: str, expected: int) -> bool:
"""统一自 ebay/tasks.py。"""
ints = re.findall(r"(?<!\d)(\d+)(?!\d)", text)
return any(int(value) == expected for value in ints)
def url_content_contains(url: str, keyword: str) -> bool:
"""统一自 crossapp3/tasks.py。"""
url = str(url or "").strip()
keyword = str(keyword or "").strip()
if not url or not keyword:
return False
try:
response = requests.get(
url,
timeout=10,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/91.0.4472.124 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
)
if not getattr(response, "ok", False):
return False
if response.encoding == "ISO-8859-1":
response.encoding = response.apparent_encoding
body = str(getattr(response, "text", "") or "")
return keyword in body or norm(keyword) in norm(body)
except Exception:
return False
def check_alternatives(
*check_arrays: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""多组按位置对应的 check 结果,返回第一组全通过的;都不过返回第一组。
每个数组长度相同,同一下标的元素属于同一个候选。
示例::
places = Map.resolve_places("故宫")
return check_alternatives(
[wechat.check_sent(addr(p), ...) for p in places],
[wechat.check_sent(weather(p), ...) for p in places],
)
"""
if not check_arrays:
return []
lengths = [len(arr) for arr in check_arrays]
if any(length == 0 for length in lengths):
raise ValueError("check_alternatives got empty candidate list")
if len(set(lengths)) != 1:
raise ValueError(f"check_alternatives got mismatched lengths: {lengths}")
n = lengths[0]
for i in range(n):
group = [arr[i] for arr in check_arrays]
if all(c.get("passed") for c in group):
return group
return [arr[0] for arr in check_arrays]