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.
Files changed (125) hide show
  1. package/bin/sophhub.js +21 -0
  2. package/package.json +32 -0
  3. package/skills/VERSIONS.md +27 -0
  4. package/skills/builtin/clawhub/SKILL.md +77 -0
  5. package/skills/builtin/flight-booking/SKILL.md +288 -0
  6. package/skills/builtin/flight-booking/scripts/flight_booking.py +1232 -0
  7. package/skills/builtin/inventory-management/SKILL.md +241 -0
  8. package/skills/builtin/inventory-management/scripts/inventory.py +1844 -0
  9. package/skills/builtin/schedule-reminder/SKILL.md +619 -0
  10. package/skills/builtin/schedule-reminder/schedule_template.md +68 -0
  11. package/skills/builtin/schedule-reminder/scripts/append_event.py +204 -0
  12. package/skills/builtin/schedule-reminder/scripts/create_reminders.sh +163 -0
  13. package/skills/builtin/schedule-reminder/scripts/daily_activate.sh +175 -0
  14. package/skills/builtin/schedule-reminder/scripts/parse_schedule.py +704 -0
  15. package/skills/builtin/schedule-reminder/scripts/setup.sh +242 -0
  16. package/skills/builtin/schedule-reminder//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -0
  17. package/skills/builtin/skill-creator/SKILL.md +370 -0
  18. package/skills/builtin/skill-creator/license.txt +202 -0
  19. package/skills/builtin/skill-creator/scripts/init_skill.py +378 -0
  20. package/skills/builtin/skill-creator/scripts/package_skill.py +111 -0
  21. package/skills/builtin/skill-creator/scripts/quick_validate.py +101 -0
  22. package/skills/builtin/sophnet-customer-management/SKILL.md +271 -0
  23. package/skills/builtin/sophnet-customer-management/pyproject.toml +15 -0
  24. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__init__.py +2 -0
  25. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/__main__.py +5 -0
  26. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/cli.py +67 -0
  27. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/__init__.py +2 -0
  28. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/customer.py +60 -0
  29. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/export_file.py +18 -0
  30. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/import_file.py +15 -0
  31. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/reminder.py +26 -0
  32. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/commands/schema.py +28 -0
  33. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_cli/config.py +54 -0
  34. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/__init__.py +2 -0
  35. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/exporter.py +85 -0
  36. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/models.py +84 -0
  37. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/normalizer.py +144 -0
  38. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/parser.py +241 -0
  39. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/query.py +109 -0
  40. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/reminder.py +121 -0
  41. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/repository.py +397 -0
  42. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/schema.py +106 -0
  43. package/skills/builtin/sophnet-customer-management/src/customer_mgmt_core/service.py +565 -0
  44. package/skills/builtin/sophnet-customer-management/uv.lock +48 -0
  45. package/skills/builtin/sophnet-customized-marketing/SKILL.md +144 -0
  46. package/skills/builtin/sophnet-customized-marketing/playbooks/campaign-planning.md +187 -0
  47. package/skills/builtin/sophnet-customized-marketing/playbooks/content-generation.md +124 -0
  48. package/skills/builtin/sophnet-customized-marketing/playbooks/marketing-calendar.md +59 -0
  49. package/skills/builtin/sophnet-customized-marketing/playbooks/multi-channel-bundle.md +94 -0
  50. package/skills/builtin/sophnet-customized-marketing/playbooks/poster-generation.md +182 -0
  51. package/skills/builtin/sophnet-customized-marketing/playbooks/style-profile-workflow.md +103 -0
  52. package/skills/builtin/sophnet-customized-marketing/pyproject.toml +9 -0
  53. package/skills/builtin/sophnet-customized-marketing/references/campaign-mechanics.md +168 -0
  54. package/skills/builtin/sophnet-customized-marketing/references/content-safety.md +26 -0
  55. package/skills/builtin/sophnet-customized-marketing/references/marketing-date-checklist.md +99 -0
  56. package/skills/builtin/sophnet-customized-marketing/references/platform-writing-guidelines.md +88 -0
  57. package/skills/builtin/sophnet-customized-marketing/references/quality-checklist.md +44 -0
  58. package/skills/builtin/sophnet-customized-marketing/scripts/generate_poster.py +585 -0
  59. package/skills/builtin/sophnet-customized-marketing/scripts/style_profile.py +215 -0
  60. package/skills/builtin/sophnet-face-search/SKILL.md +115 -0
  61. package/skills/builtin/sophnet-face-search/pyproject.toml +11 -0
  62. package/skills/builtin/sophnet-face-search/scripts/face_search.py +336 -0
  63. package/skills/builtin/sophnet-face-search/uv.lock +508 -0
  64. package/skills/builtin/sophnet-image-edit/SKILL.md +140 -0
  65. package/skills/builtin/sophnet-image-edit/pyproject.toml +9 -0
  66. package/skills/builtin/sophnet-image-edit/scripts/edit_and_preview.sh +68 -0
  67. package/skills/builtin/sophnet-image-edit/scripts/edit_image.py +279 -0
  68. package/skills/builtin/sophnet-image-edit/uv.lock +234 -0
  69. package/skills/builtin/sophnet-image-generate/SKILL.md +62 -0
  70. package/skills/builtin/sophnet-image-generate/pyproject.toml +9 -0
  71. package/skills/builtin/sophnet-image-generate/scripts/generate_image.py +156 -0
  72. package/skills/builtin/sophnet-image-generate/uv.lock +234 -0
  73. package/skills/builtin/sophnet-image-ocr/SKILL.md +167 -0
  74. package/skills/builtin/sophnet-image-ocr/pyproject.toml +13 -0
  75. package/skills/builtin/sophnet-image-ocr/scripts/ocr.py +226 -0
  76. package/skills/builtin/sophnet-image-ocr/uv.lock +234 -0
  77. package/skills/builtin/sophnet-infinite-talk/SKILL.md +140 -0
  78. package/skills/builtin/sophnet-infinite-talk/pyproject.toml +9 -0
  79. package/skills/builtin/sophnet-infinite-talk/scripts/gen.py +172 -0
  80. package/skills/builtin/sophnet-oss/SKILL.md +109 -0
  81. package/skills/builtin/sophnet-oss/pyproject.toml +8 -0
  82. package/skills/builtin/sophnet-oss/scripts/upload_file.py +43 -0
  83. package/skills/builtin/sophnet-qa-install/SKILL.md +210 -0
  84. package/skills/builtin/sophnet-qa-install/pyproject.toml +6 -0
  85. package/skills/builtin/sophnet-qa-install/scripts/backup_md.py +35 -0
  86. package/skills/builtin/sophnet-qa-install/scripts/check_installed.py +143 -0
  87. package/skills/builtin/sophnet-qa-install/scripts/update_config.py +142 -0
  88. package/skills/builtin/sophnet-qa-install/scripts/update_md.py +73 -0
  89. package/skills/builtin/sophnet-training-install/SKILL.md +211 -0
  90. package/skills/builtin/sophnet-training-install/pyproject.toml +6 -0
  91. package/skills/builtin/sophnet-training-install/scripts/backup_md.py +35 -0
  92. package/skills/builtin/sophnet-training-install/scripts/check_installed.py +144 -0
  93. package/skills/builtin/sophnet-training-install/scripts/update_config.py +142 -0
  94. package/skills/builtin/sophnet-training-install/scripts/update_md.py +73 -0
  95. package/skills/builtin/sophnet-tts/SKILL.md +79 -0
  96. package/skills/builtin/sophnet-tts/pyproject.toml +9 -0
  97. package/skills/builtin/sophnet-tts/scripts/gen_tts.py +130 -0
  98. package/skills/builtin/sophnet-video-generate/SKILL.md +116 -0
  99. package/skills/builtin/sophnet-video-generate/scripts/gen_video.py +304 -0
  100. package/skills/builtin/video-understand/SKILL.md +79 -0
  101. package/skills/builtin/video-understand/scripts/video_understand.py +204 -0
  102. package/skills/builtin/weather/SKILL.md +112 -0
  103. package/skills/builtin/web-scraper/SKILL.md +101 -0
  104. package/skills/builtin/web-scraper/scripts/scrape.py +270 -0
  105. package/skills/builtin/website-builder/SKILL.md +266 -0
  106. package/skills/builtin/website-builder/scripts/deploy_site.sh +46 -0
  107. package/skills/store/didi-ride/SKILL.md +309 -0
  108. package/skills/store/didi-ride/_meta.json +6 -0
  109. package/skills/store/didi-ride/assets/PREFERENCE.md +58 -0
  110. package/skills/store/didi-ride/package.json +15 -0
  111. package/skills/store/didi-ride/references/api_references.md +171 -0
  112. package/skills/store/didi-ride/references/error_handling.md +68 -0
  113. package/skills/store/didi-ride/references/setup.md +73 -0
  114. package/skills/store/didi-ride/references/workflow.md +150 -0
  115. package/skills/store/flyai/SKILL.md +119 -0
  116. package/skills/store/flyai/references/fliggy-fast-search.md +53 -0
  117. package/skills/store/flyai/references/search-flight.md +89 -0
  118. package/skills/store/flyai/references/search-hotels.md +57 -0
  119. package/skills/store/flyai/references/search-poi.md +49 -0
  120. package/src/commands/download.js +103 -0
  121. package/src/commands/list.js +67 -0
  122. package/src/utils/config.js +24 -0
  123. package/src/utils/gitlab.js +67 -0
  124. package/src/utils/paths.js +19 -0
  125. 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}"