sophhub 0.1.0
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.
- package/bin/sophhub.js +21 -0
- package/package.json +32 -0
- package/skills/VERSIONS.md +27 -0
- package/skills/builtin/clawhub/SKILL.md +77 -0
- package/skills/builtin/flight-booking/SKILL.md +288 -0
- package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
- package/skills/builtin/inventory-management/SKILL.md +241 -0
- package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
- package/skills/builtin/schedule-reminder/SKILL.md +619 -0
- package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
- package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
- package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
- package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
- package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
- package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
- package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
- package/skills/builtin/skill-creator/SKILL.md +370 -0
- package/skills/builtin/skill-creator/license.txt +202 -0
- package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
- package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
- package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
- package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
- package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
- package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
- package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
- package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
- package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
- package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
- package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
- package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
- package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
- package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
- package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
- package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
- package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
- package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
- package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
- package/skills/builtin/sophnet-face-search/uv.lock +508 -0
- package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
- package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
- package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
- package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
- package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
- package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
- package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
- package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
- package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
- package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
- package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
- package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
- package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
- package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
- package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
- package/skills/builtin/sophnet-oss/SKILL.md +109 -0
- package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
- package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
- package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
- package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
- package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
- package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
- package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
- package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
- package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
- package/skills/builtin/sophnet-tts/SKILL.md +79 -0
- package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
- package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
- package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
- package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
- package/skills/builtin/video-understand/SKILL.md +79 -0
- package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
- package/skills/builtin/weather/SKILL.md +112 -0
- package/skills/builtin/web-scraper/SKILL.md +101 -0
- package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
- package/skills/builtin/website-builder/SKILL.md +266 -0
- package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
- package/skills/store/didi-ride/SKILL.md +309 -0
- package/skills/store/didi-ride/_meta.json +6 -0
- package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
- package/skills/store/didi-ride/package.json +15 -0
- package/skills/store/didi-ride/references/api_references.md +171 -0
- package/skills/store/didi-ride/references/error_handling.md +68 -0
- package/skills/store/didi-ride/references/setup.md +73 -0
- package/skills/store/didi-ride/references/workflow.md +150 -0
- package/skills/store/flyai/SKILL.md +119 -0
- package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
- package/skills/store/flyai/references/search-flight.md +89 -0
- package/skills/store/flyai/references/search-hotels.md +57 -0
- package/skills/store/flyai/references/search-poi.md +49 -0
- package/src/commands/download.js +103 -0
- package/src/commands/list.js +67 -0
- package/src/utils/config.js +24 -0
- package/src/utils/gitlab.js +67 -0
- package/src/utils/paths.js +19 -0
- package/src/utils/versions.js +38 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
BUILTIN_CUSTOMER_FIELDS = {
|
|
8
|
+
"name",
|
|
9
|
+
"primary_phone",
|
|
10
|
+
"birthday",
|
|
11
|
+
"tags",
|
|
12
|
+
"notes",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class FieldDefinition:
|
|
18
|
+
field_key: str
|
|
19
|
+
label: str
|
|
20
|
+
value_type: str = "string"
|
|
21
|
+
entity_type: str = "customer"
|
|
22
|
+
is_required: bool = False
|
|
23
|
+
source: str = "user_confirmed"
|
|
24
|
+
status: str = "active"
|
|
25
|
+
aliases: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return asdict(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class CustomerPayload:
|
|
33
|
+
name: str
|
|
34
|
+
primary_phone: str | None = None
|
|
35
|
+
birthday: str | None = None
|
|
36
|
+
tags: list[str] = field(default_factory=list)
|
|
37
|
+
notes: str | None = None
|
|
38
|
+
dynamic_fields: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class BusinessRecordPayload:
|
|
46
|
+
customer_id: str
|
|
47
|
+
record_type: str
|
|
48
|
+
status: str | None = None
|
|
49
|
+
record_date: str | None = None
|
|
50
|
+
amount: str | None = None
|
|
51
|
+
title: str | None = None
|
|
52
|
+
raw_payload: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return asdict(self)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class ReminderPlan:
|
|
60
|
+
customer_id: str
|
|
61
|
+
customer_name: str
|
|
62
|
+
reminder_type: str
|
|
63
|
+
title: str
|
|
64
|
+
trigger_at: str | None
|
|
65
|
+
cron_expr: str
|
|
66
|
+
message: str
|
|
67
|
+
recurring: bool = False
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class ImportPreview:
|
|
75
|
+
mode: str
|
|
76
|
+
source_file: str
|
|
77
|
+
customer_count: int
|
|
78
|
+
record_count: int
|
|
79
|
+
new_field_count: int
|
|
80
|
+
warnings: list[str] = field(default_factory=list)
|
|
81
|
+
preview_rows: list[dict[str, Any]] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
return asdict(self)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
NULL_MARKERS = {"", "nan", "none", "null", "nat", "未填写", "暂无", "-"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clean_text(value: Any) -> str:
|
|
14
|
+
if value is None:
|
|
15
|
+
return ""
|
|
16
|
+
text = str(value).strip()
|
|
17
|
+
text = text.replace("\u3000", " ")
|
|
18
|
+
text = re.sub(r"\s+", " ", text)
|
|
19
|
+
return text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_blank(value: Any) -> bool:
|
|
23
|
+
return clean_text(value).lower() in NULL_MARKERS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_optional_text(value: Any) -> str | None:
|
|
27
|
+
text = clean_text(value)
|
|
28
|
+
if text.lower() in NULL_MARKERS:
|
|
29
|
+
return None
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_phone(value: Any) -> str | None:
|
|
34
|
+
text = clean_text(value)
|
|
35
|
+
if not text:
|
|
36
|
+
return None
|
|
37
|
+
digits = re.sub(r"\D+", "", text)
|
|
38
|
+
if len(digits) >= 11:
|
|
39
|
+
return digits[-11:]
|
|
40
|
+
if len(digits) >= 7:
|
|
41
|
+
return digits
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_date(value: Any) -> str | None:
|
|
46
|
+
text = clean_text(value)
|
|
47
|
+
if not text or text.lower() in NULL_MARKERS:
|
|
48
|
+
return None
|
|
49
|
+
text = text.replace("年", ".").replace("月", ".").replace("日", "")
|
|
50
|
+
text = text.replace("/", ".").replace("-", ".")
|
|
51
|
+
text = re.sub(r"\.+", ".", text).strip(".")
|
|
52
|
+
candidates = [
|
|
53
|
+
"%Y.%m.%d",
|
|
54
|
+
"%Y.%m",
|
|
55
|
+
"%m.%d",
|
|
56
|
+
]
|
|
57
|
+
for fmt in candidates:
|
|
58
|
+
try:
|
|
59
|
+
parsed = datetime.strptime(text, fmt)
|
|
60
|
+
year = parsed.year
|
|
61
|
+
if fmt == "%m.%d":
|
|
62
|
+
year = date.today().year
|
|
63
|
+
month = parsed.month
|
|
64
|
+
day = parsed.day if "%d" in fmt else 1
|
|
65
|
+
return date(year, month, day).isoformat()
|
|
66
|
+
except ValueError:
|
|
67
|
+
continue
|
|
68
|
+
if re.fullmatch(r"\d{4}\.\d{1,2}\.\d{1,2}", text):
|
|
69
|
+
parts = [int(item) for item in text.split(".")]
|
|
70
|
+
return date(parts[0], parts[1], parts[2]).isoformat()
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_tags(value: Any) -> list[str]:
|
|
75
|
+
if isinstance(value, (list, tuple, set)):
|
|
76
|
+
return [clean_text(item) for item in value if clean_text(item)]
|
|
77
|
+
text = clean_text(value)
|
|
78
|
+
if not text:
|
|
79
|
+
return []
|
|
80
|
+
parts = re.split(r"[,,、/\s]+", text)
|
|
81
|
+
return [item for item in (part.strip() for part in parts) if item]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_money(value: Any) -> str | None:
|
|
85
|
+
text = clean_text(value)
|
|
86
|
+
if not text or text.lower() in NULL_MARKERS:
|
|
87
|
+
return None
|
|
88
|
+
return text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_value_for_type(value: Any, value_type: str) -> str | None:
|
|
92
|
+
if value_type == "date":
|
|
93
|
+
return normalize_date(value)
|
|
94
|
+
if value_type == "phone":
|
|
95
|
+
return normalize_phone(value)
|
|
96
|
+
if value_type == "tags":
|
|
97
|
+
tags = normalize_tags(value)
|
|
98
|
+
return json.dumps(tags, ensure_ascii=False)
|
|
99
|
+
if value_type == "money":
|
|
100
|
+
return normalize_money(value)
|
|
101
|
+
return normalize_optional_text(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def guess_value_type(label: str, values: list[Any]) -> str:
|
|
105
|
+
joined_label = clean_text(label).lower()
|
|
106
|
+
if any(keyword in joined_label for keyword in ["微信", "wechat", "wx"]):
|
|
107
|
+
return "string"
|
|
108
|
+
if any(keyword in joined_label for keyword in ["电话", "手机", "手机号", "mobile", "phone"]):
|
|
109
|
+
return "phone"
|
|
110
|
+
if any(keyword in joined_label for keyword in ["生日", "日期", "时间", "生效", "到期", "续费", "回访", "预约"]):
|
|
111
|
+
return "date"
|
|
112
|
+
if any(keyword in joined_label for keyword in ["金额", "保费", "保额", "消费", "价格", "佣金"]):
|
|
113
|
+
return "money"
|
|
114
|
+
|
|
115
|
+
non_blank = [clean_text(value) for value in values if not is_blank(value)]
|
|
116
|
+
if not non_blank:
|
|
117
|
+
return "string"
|
|
118
|
+
|
|
119
|
+
date_hits = sum(1 for value in non_blank if normalize_date(value))
|
|
120
|
+
if date_hits == len(non_blank):
|
|
121
|
+
return "date"
|
|
122
|
+
|
|
123
|
+
phone_hits = sum(1 for value in non_blank if normalize_phone(value))
|
|
124
|
+
if phone_hits == len(non_blank):
|
|
125
|
+
return "phone"
|
|
126
|
+
|
|
127
|
+
numeric_hits = sum(1 for value in non_blank if re.fullmatch(r"[\d.]+", value))
|
|
128
|
+
if numeric_hits == len(non_blank):
|
|
129
|
+
return "number"
|
|
130
|
+
|
|
131
|
+
return "string"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def stable_field_key(label: str) -> str:
|
|
135
|
+
text = clean_text(label).lower()
|
|
136
|
+
ascii_text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
|
137
|
+
if ascii_text:
|
|
138
|
+
return ascii_text[:48]
|
|
139
|
+
digest = hashlib.sha1(clean_text(label).encode("utf-8")).hexdigest()[:12]
|
|
140
|
+
return f"fld_{digest}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def dumps_json(data: Any) -> str:
|
|
144
|
+
return json.dumps(data, ensure_ascii=False, sort_keys=True)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import openpyxl
|
|
9
|
+
import xlrd
|
|
10
|
+
|
|
11
|
+
from .normalizer import clean_text, is_blank
|
|
12
|
+
from .schema import resolve_grouped_record_field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_file(file_path: str | Path) -> dict[str, Any]:
|
|
16
|
+
path = Path(file_path)
|
|
17
|
+
suffix = path.suffix.lower()
|
|
18
|
+
if suffix in {".csv"}:
|
|
19
|
+
tables = [_grid_to_table(_read_csv_grid(path), title=path.stem)]
|
|
20
|
+
elif suffix in {".xlsx"}:
|
|
21
|
+
tables = _read_xlsx_tables(path)
|
|
22
|
+
elif suffix in {".xls"}:
|
|
23
|
+
tables = _read_xls_tables(path)
|
|
24
|
+
elif suffix in {".md", ".markdown", ".txt"}:
|
|
25
|
+
tables = _read_markdown_tables(path)
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError(f"暂不支持的文件类型: {suffix}")
|
|
28
|
+
|
|
29
|
+
filtered_tables = [table for table in tables if table and table["rows"]]
|
|
30
|
+
return {
|
|
31
|
+
"source_file": str(path),
|
|
32
|
+
"format": suffix.lstrip("."),
|
|
33
|
+
"tables": filtered_tables,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_csv_grid(path: Path) -> list[list[str]]:
|
|
38
|
+
with path.open("r", encoding="utf-8-sig", newline="") as file:
|
|
39
|
+
return [[clean_text(cell) for cell in row] for row in csv.reader(file)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_xlsx_tables(path: Path) -> list[dict[str, Any]]:
|
|
43
|
+
workbook = openpyxl.load_workbook(path, data_only=True)
|
|
44
|
+
tables: list[dict[str, Any]] = []
|
|
45
|
+
for sheet in workbook.worksheets:
|
|
46
|
+
grid = [
|
|
47
|
+
[sheet.cell(row_idx, col_idx).value for col_idx in range(1, sheet.max_column + 1)]
|
|
48
|
+
for row_idx in range(1, sheet.max_row + 1)
|
|
49
|
+
]
|
|
50
|
+
for merged in sheet.merged_cells.ranges:
|
|
51
|
+
value = grid[merged.min_row - 1][merged.min_col - 1]
|
|
52
|
+
for row_idx in range(merged.min_row - 1, merged.max_row):
|
|
53
|
+
for col_idx in range(merged.min_col - 1, merged.max_col):
|
|
54
|
+
grid[row_idx][col_idx] = value
|
|
55
|
+
grid = [[clean_text(cell) for cell in row] for row in grid]
|
|
56
|
+
table = _grid_to_table(grid, title=sheet.title)
|
|
57
|
+
if table:
|
|
58
|
+
tables.append(table)
|
|
59
|
+
return tables
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_xls_tables(path: Path) -> list[dict[str, Any]]:
|
|
63
|
+
workbook = xlrd.open_workbook(path, formatting_info=True)
|
|
64
|
+
tables: list[dict[str, Any]] = []
|
|
65
|
+
for index in range(workbook.nsheets):
|
|
66
|
+
sheet = workbook.sheet_by_index(index)
|
|
67
|
+
grid = [
|
|
68
|
+
[sheet.cell_value(row_idx, col_idx) for col_idx in range(sheet.ncols)]
|
|
69
|
+
for row_idx in range(sheet.nrows)
|
|
70
|
+
]
|
|
71
|
+
for row_lo, row_hi, col_lo, col_hi in sheet.merged_cells:
|
|
72
|
+
value = grid[row_lo][col_lo]
|
|
73
|
+
for row_idx in range(row_lo, row_hi):
|
|
74
|
+
for col_idx in range(col_lo, col_hi):
|
|
75
|
+
grid[row_idx][col_idx] = value
|
|
76
|
+
grid = [[clean_text(cell) for cell in row] for row in grid]
|
|
77
|
+
table = _grid_to_table(grid, title=sheet.name)
|
|
78
|
+
if table:
|
|
79
|
+
tables.append(table)
|
|
80
|
+
return tables
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_markdown_tables(path: Path) -> list[dict[str, Any]]:
|
|
84
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
85
|
+
blocks: list[list[list[str]]] = []
|
|
86
|
+
current: list[list[str]] = []
|
|
87
|
+
|
|
88
|
+
def flush_current() -> None:
|
|
89
|
+
nonlocal current
|
|
90
|
+
if current:
|
|
91
|
+
blocks.append(current)
|
|
92
|
+
current = []
|
|
93
|
+
|
|
94
|
+
for line in lines:
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if not stripped.startswith("|"):
|
|
97
|
+
flush_current()
|
|
98
|
+
continue
|
|
99
|
+
cells = [clean_text(cell) for cell in stripped.strip("|").split("|")]
|
|
100
|
+
if all(re.fullmatch(r"[-:\s]+", cell or "-") for cell in cells):
|
|
101
|
+
continue
|
|
102
|
+
current.append(cells)
|
|
103
|
+
flush_current()
|
|
104
|
+
tables = []
|
|
105
|
+
for index, block in enumerate(blocks):
|
|
106
|
+
table = _grid_to_table(block, title=f"table_{index + 1}")
|
|
107
|
+
if table:
|
|
108
|
+
tables.append(table)
|
|
109
|
+
return tables
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _grid_to_table(grid: list[list[str]], title: str) -> dict[str, Any] | None:
|
|
113
|
+
rows = [_trim_row(row) for row in grid if any(not is_blank(cell) for cell in row)]
|
|
114
|
+
if not rows:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
title_row = None
|
|
118
|
+
if _looks_like_title_row(rows[0]):
|
|
119
|
+
title_row = rows.pop(0)
|
|
120
|
+
if not rows:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
header_start = 0
|
|
124
|
+
headers = rows[0]
|
|
125
|
+
data_start = 1
|
|
126
|
+
if _has_second_header_row(rows):
|
|
127
|
+
headers = _combine_headers(rows[0], rows[1])
|
|
128
|
+
data_start = 2
|
|
129
|
+
headers = _dedupe_headers(headers)
|
|
130
|
+
if not any(headers):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
mode = "grouped_records" if _looks_like_grouped_record_headers(headers) else "generic"
|
|
134
|
+
parsed_rows = _rows_to_dicts(rows[data_start:], headers, forward_fill=(mode == "grouped_records"))
|
|
135
|
+
return {
|
|
136
|
+
"title": clean_text(title_row[0]) if title_row else title,
|
|
137
|
+
"headers": headers,
|
|
138
|
+
"rows": parsed_rows,
|
|
139
|
+
"mode": mode,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _trim_row(row: list[str]) -> list[str]:
|
|
144
|
+
cleaned = [clean_text(cell) for cell in row]
|
|
145
|
+
while cleaned and is_blank(cleaned[-1]):
|
|
146
|
+
cleaned.pop()
|
|
147
|
+
return cleaned
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _looks_like_title_row(row: list[str]) -> bool:
|
|
151
|
+
first = clean_text(row[0]) if row else ""
|
|
152
|
+
if "客户档案" in first:
|
|
153
|
+
return True
|
|
154
|
+
unnamed_count = sum(1 for cell in row if "unnamed" in clean_text(cell).lower())
|
|
155
|
+
return unnamed_count >= max(2, len(row) // 3)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _has_second_header_row(rows: list[list[str]]) -> bool:
|
|
159
|
+
if len(rows) < 2:
|
|
160
|
+
return False
|
|
161
|
+
second_row = rows[1]
|
|
162
|
+
markers = {"姓名", "性别", "生日"}
|
|
163
|
+
return sum(1 for cell in second_row if clean_text(cell) in markers) >= 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _combine_headers(main_headers: list[str], sub_headers: list[str]) -> list[str]:
|
|
167
|
+
result: list[str] = []
|
|
168
|
+
current_group = ""
|
|
169
|
+
for index, main in enumerate(main_headers):
|
|
170
|
+
sub = sub_headers[index] if index < len(sub_headers) else ""
|
|
171
|
+
main_text = clean_text(main)
|
|
172
|
+
sub_text = clean_text(sub)
|
|
173
|
+
if not is_blank(main_text):
|
|
174
|
+
current_group = main_text
|
|
175
|
+
elif current_group and sub_text:
|
|
176
|
+
main_text = current_group
|
|
177
|
+
if is_blank(main_text):
|
|
178
|
+
result.append(sub_text)
|
|
179
|
+
continue
|
|
180
|
+
if is_blank(sub_text):
|
|
181
|
+
result.append(main_text)
|
|
182
|
+
continue
|
|
183
|
+
if sub_text == main_text:
|
|
184
|
+
result.append(main_text)
|
|
185
|
+
continue
|
|
186
|
+
result.append(f"{main_text}_{sub_text}")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _dedupe_headers(headers: list[str]) -> list[str]:
|
|
191
|
+
seen: dict[str, int] = {}
|
|
192
|
+
result: list[str] = []
|
|
193
|
+
for index, header in enumerate(headers):
|
|
194
|
+
text = clean_text(header)
|
|
195
|
+
if not text or text.lower().startswith("unnamed"):
|
|
196
|
+
text = f"column_{index + 1}"
|
|
197
|
+
count = seen.get(text, 0)
|
|
198
|
+
seen[text] = count + 1
|
|
199
|
+
result.append(text if count == 0 else f"{text}_{count + 1}")
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _looks_like_grouped_record_headers(headers: list[str]) -> bool:
|
|
204
|
+
grouped_hits = 0
|
|
205
|
+
normalized_hits = 0
|
|
206
|
+
for header in headers:
|
|
207
|
+
if "_" in header:
|
|
208
|
+
prefix, suffix = header.split("_", 1)
|
|
209
|
+
if prefix and suffix in {"姓名", "性别", "生日", "出生日期", "手机", "手机号", "电话", "联系电话"}:
|
|
210
|
+
grouped_hits += 1
|
|
211
|
+
if resolve_grouped_record_field(header):
|
|
212
|
+
normalized_hits += 1
|
|
213
|
+
return grouped_hits >= 2 or normalized_hits >= 3
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _rows_to_dicts(rows: list[list[str]], headers: list[str], *, forward_fill: bool) -> list[dict[str, str]]:
|
|
217
|
+
output: list[dict[str, str]] = []
|
|
218
|
+
previous_values = {header: "" for header in headers}
|
|
219
|
+
for raw_row in rows:
|
|
220
|
+
if not raw_row:
|
|
221
|
+
continue
|
|
222
|
+
row: dict[str, str] = {}
|
|
223
|
+
for index, header in enumerate(headers):
|
|
224
|
+
value = clean_text(raw_row[index]) if index < len(raw_row) else ""
|
|
225
|
+
if forward_fill and is_blank(value):
|
|
226
|
+
value = previous_values.get(header, "")
|
|
227
|
+
row[header] = value
|
|
228
|
+
if not is_blank(value):
|
|
229
|
+
previous_values[header] = value
|
|
230
|
+
if _is_meaningful_row(row):
|
|
231
|
+
output.append(row)
|
|
232
|
+
return output
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_meaningful_row(row: dict[str, str]) -> bool:
|
|
236
|
+
non_blank_values = [value for value in row.values() if not is_blank(value)]
|
|
237
|
+
if not non_blank_values:
|
|
238
|
+
return False
|
|
239
|
+
if len(non_blank_values) == 1 and non_blank_values[0].isdigit():
|
|
240
|
+
return False
|
|
241
|
+
return True
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .normalizer import clean_text, normalize_date, normalize_phone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def match_filters(customer: dict[str, Any], query: dict[str, Any]) -> bool:
|
|
10
|
+
customer_id = clean_text(query.get("customer_id"))
|
|
11
|
+
if customer_id and customer_id != clean_text(customer.get("customer_id")):
|
|
12
|
+
return False
|
|
13
|
+
|
|
14
|
+
name = clean_text(query.get("name"))
|
|
15
|
+
if name and name not in clean_text(customer.get("name")):
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
phone = normalize_phone(query.get("primary_phone") or query.get("phone"))
|
|
19
|
+
if phone:
|
|
20
|
+
customer_phone = normalize_phone(customer.get("primary_phone"))
|
|
21
|
+
if not customer_phone or phone not in customer_phone:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
birthday_month = query.get("birthday_month")
|
|
25
|
+
if birthday_month:
|
|
26
|
+
birthday = normalize_date(customer.get("birthday"))
|
|
27
|
+
if not birthday or int(birthday.split("-")[1]) != int(birthday_month):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
tags = query.get("tags") or []
|
|
31
|
+
if query.get("tag"):
|
|
32
|
+
tags = [query["tag"], *tags]
|
|
33
|
+
if tags:
|
|
34
|
+
customer_tags = set(customer.get("tags", []))
|
|
35
|
+
if not set(tags).issubset(customer_tags):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
dynamic_filters = query.get("dynamic_filters") or []
|
|
39
|
+
field_map = customer.get("dynamic_field_map", {})
|
|
40
|
+
for item in dynamic_filters:
|
|
41
|
+
field_key = item.get("field_key") or item.get("field")
|
|
42
|
+
if not field_key:
|
|
43
|
+
return False
|
|
44
|
+
data = field_map.get(field_key)
|
|
45
|
+
if not data:
|
|
46
|
+
data = next(
|
|
47
|
+
(
|
|
48
|
+
candidate
|
|
49
|
+
for candidate in field_map.values()
|
|
50
|
+
if clean_text(candidate.get("label")) == clean_text(field_key)
|
|
51
|
+
),
|
|
52
|
+
None,
|
|
53
|
+
)
|
|
54
|
+
if not data:
|
|
55
|
+
return False
|
|
56
|
+
op = item.get("op", "contains")
|
|
57
|
+
expected = clean_text(item.get("value"))
|
|
58
|
+
actual = clean_text(data.get("displayValue") or data.get("value"))
|
|
59
|
+
if op == "eq" and actual != expected:
|
|
60
|
+
return False
|
|
61
|
+
if op == "contains" and expected not in actual:
|
|
62
|
+
return False
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sort_customers(customers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
67
|
+
return sorted(
|
|
68
|
+
customers,
|
|
69
|
+
key=lambda item: (
|
|
70
|
+
item.get("birthday") or "9999-12-31",
|
|
71
|
+
item.get("name") or "",
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def select_due_fields(customer: dict[str, Any]) -> list[dict[str, Any]]:
|
|
77
|
+
results = []
|
|
78
|
+
for field_key, value in customer.get("dynamic_field_map", {}).items():
|
|
79
|
+
label = clean_text(value.get("label", ""))
|
|
80
|
+
normalized = normalize_date(value.get("value"))
|
|
81
|
+
if not normalized:
|
|
82
|
+
continue
|
|
83
|
+
if any(keyword in label for keyword in ["到期", "续费", "回访", "预约"]):
|
|
84
|
+
results.append(
|
|
85
|
+
{
|
|
86
|
+
"field_key": field_key,
|
|
87
|
+
"label": label,
|
|
88
|
+
"date": normalized,
|
|
89
|
+
"display_value": value.get("displayValue") or value.get("value"),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
return results
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def query_due_within_days(customers: list[dict[str, Any]], days: int) -> list[dict[str, Any]]:
|
|
96
|
+
today = date.today()
|
|
97
|
+
end_date = today + timedelta(days=days)
|
|
98
|
+
matched: list[dict[str, Any]] = []
|
|
99
|
+
for customer in customers:
|
|
100
|
+
due_fields = []
|
|
101
|
+
for item in select_due_fields(customer):
|
|
102
|
+
due_date = date.fromisoformat(item["date"])
|
|
103
|
+
if today <= due_date <= end_date:
|
|
104
|
+
due_fields.append(item)
|
|
105
|
+
if due_fields:
|
|
106
|
+
cloned = dict(customer)
|
|
107
|
+
cloned["due_fields"] = due_fields
|
|
108
|
+
matched.append(cloned)
|
|
109
|
+
return matched
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import ReminderPlan
|
|
9
|
+
from .query import select_due_fields
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_birthday_plans(customers: list[dict[str, Any]]) -> list[ReminderPlan]:
|
|
13
|
+
plans: list[ReminderPlan] = []
|
|
14
|
+
for customer in customers:
|
|
15
|
+
birthday = customer.get("birthday")
|
|
16
|
+
if not birthday:
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
month = int(birthday.split("-")[1])
|
|
20
|
+
day = int(birthday.split("-")[2])
|
|
21
|
+
except (IndexError, ValueError):
|
|
22
|
+
continue
|
|
23
|
+
cron_expr = f"0 9 {day} {month} *"
|
|
24
|
+
plans.append(
|
|
25
|
+
ReminderPlan(
|
|
26
|
+
customer_id=customer["customer_id"],
|
|
27
|
+
customer_name=customer["name"],
|
|
28
|
+
reminder_type="birthday",
|
|
29
|
+
title=f"{customer['name']} 生日提醒",
|
|
30
|
+
trigger_at=None,
|
|
31
|
+
cron_expr=cron_expr,
|
|
32
|
+
message=f"🎂 客户 {customer['name']} 今天生日,记得送上祝福或做一次回访。",
|
|
33
|
+
recurring=True,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return plans
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_due_plans(customers: list[dict[str, Any]], *, days: tuple[int, ...] = (30, 7, 1)) -> list[ReminderPlan]:
|
|
40
|
+
today = date.today()
|
|
41
|
+
plans: list[ReminderPlan] = []
|
|
42
|
+
for customer in customers:
|
|
43
|
+
for item in select_due_fields(customer):
|
|
44
|
+
due_date = date.fromisoformat(item["date"])
|
|
45
|
+
for before_days in days:
|
|
46
|
+
trigger_date = due_date - timedelta(days=before_days)
|
|
47
|
+
if trigger_date < today:
|
|
48
|
+
continue
|
|
49
|
+
cron_expr = f"0 9 {trigger_date.day} {trigger_date.month} *"
|
|
50
|
+
plans.append(
|
|
51
|
+
ReminderPlan(
|
|
52
|
+
customer_id=customer["customer_id"],
|
|
53
|
+
customer_name=customer["name"],
|
|
54
|
+
reminder_type="due_date",
|
|
55
|
+
title=f"{customer['name']} {item['label']}提醒",
|
|
56
|
+
trigger_at=trigger_date.isoformat(),
|
|
57
|
+
cron_expr=cron_expr,
|
|
58
|
+
message=(
|
|
59
|
+
f"⏰ 客户 {customer['name']} 的「{item['label']}」将在 {before_days} 天后到来,"
|
|
60
|
+
f"当前记录日期为 {item['display_value']}。"
|
|
61
|
+
),
|
|
62
|
+
recurring=False,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return plans
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_follow_up_plan(customer: dict[str, Any], follow_up_date: str) -> ReminderPlan:
|
|
69
|
+
due_date = date.fromisoformat(follow_up_date)
|
|
70
|
+
cron_expr = f"0 9 {due_date.day} {due_date.month} *"
|
|
71
|
+
return ReminderPlan(
|
|
72
|
+
customer_id=customer["customer_id"],
|
|
73
|
+
customer_name=customer["name"],
|
|
74
|
+
reminder_type="follow_up",
|
|
75
|
+
title=f"{customer['name']} 跟进提醒",
|
|
76
|
+
trigger_at=follow_up_date,
|
|
77
|
+
cron_expr=cron_expr,
|
|
78
|
+
message=f"📞 记得跟进客户 {customer['name']}。",
|
|
79
|
+
recurring=False,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def schedule_plans(plans: list[ReminderPlan], *, tz: str = "Asia/Shanghai") -> list[dict[str, Any]]:
|
|
84
|
+
results = []
|
|
85
|
+
for plan in plans:
|
|
86
|
+
task_name = _build_task_name(plan)
|
|
87
|
+
command = [
|
|
88
|
+
"openclaw",
|
|
89
|
+
"cron",
|
|
90
|
+
"add",
|
|
91
|
+
"--name",
|
|
92
|
+
task_name,
|
|
93
|
+
"--cron",
|
|
94
|
+
plan.cron_expr,
|
|
95
|
+
"--tz",
|
|
96
|
+
tz,
|
|
97
|
+
"--session",
|
|
98
|
+
"main",
|
|
99
|
+
"--system-event",
|
|
100
|
+
plan.message,
|
|
101
|
+
]
|
|
102
|
+
if not plan.recurring:
|
|
103
|
+
command.append("--delete-after-run")
|
|
104
|
+
|
|
105
|
+
process = subprocess.run(command, capture_output=True, text=True)
|
|
106
|
+
results.append(
|
|
107
|
+
{
|
|
108
|
+
**asdict(plan),
|
|
109
|
+
"task_name": task_name,
|
|
110
|
+
"scheduled": process.returncode == 0,
|
|
111
|
+
"stdout": process.stdout.strip(),
|
|
112
|
+
"stderr": process.stderr.strip(),
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
return results
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_task_name(plan: ReminderPlan) -> str:
|
|
119
|
+
timestamp = plan.trigger_at or datetime.now().date().isoformat()
|
|
120
|
+
safe_name = "".join(char if char.isalnum() else "_" for char in plan.customer_name)[:24]
|
|
121
|
+
return f"customer-{plan.reminder_type}-{timestamp}-{safe_name}"
|