Skill: errsr/bali-resource-import (version 1.0.1)

--- name: bali-resource-import description: | 印尼（巴厘岛）旅游资源入库 SOP。当用户提供供应商文档（PDF/Word）并要求解析入库时， 或提到"资源入库"、"解析入库"、"印尼资源"、"巴厘岛资源"、"CSV入库"时，必须使用本技能。 覆盖8类资源：酒店、车辆、景点、活动、SPA、俱乐部、餐厅、下午茶。 使用前必须先检索同类资源 CSV 的表头格式（encoding 可能是 gbk 或 utf-8-sig）， 确保新增数据字段与生产库表头完全一致后再写入。 compatibility: Python 3.8+, PyMuPDF（PDF解析）, python-docx（Word解析）；csv 内置，redis 可选 --- # 印尼旅游资源解析入库技能 ## 触发识别（使用前必读） ### 用户提示词关键词解析 收到入库任务时，从用户提示词中自动识别以下关键信息： | 提示词关键词 | 识别结果 | 操作 | |------------|---------|------| | `https://drive.google.com/...` 等 Drive URL | 数据源为云盘 | 调用 `google-drive` 技能，**只下载合同文件**到目标路径 | | 本地路径如 `C:\Users\a\Desktop\资源` | 数据源为本地文件 | 直接使用该路径下的文件 | | `模型文件路径` / `已存在的 csv` | 目标生产库位置 | 在该 CSV 基础上**追加**新数据，而非覆盖 | | `目标路径` / `target` | 解析结果输出目录 | 解析结果写入该目录，原始合同和中间文件于 Step 8 清理 | | 未提及目标路径 | — | 告知用户必须指定目标路径 | **⚠️ 路径仅为示例，不可硬编码。** 所有路径必须从用户提示词中动态提取，用作字段值传递给脚本。 **示例提示词：** > "检索 https://drive.google.com/drive/my-drive 所有合同并解析，模型文件路径在 `C:\Users\a\Desktop\资源`，目标路径为 `C:\Users\a\Desktop\资源\target`" **动态提取结果（作为变量使用）：** - **数据源 URL**：`https://drive.google.com/drive/my-drive` → 传给 `google-drive` 技能 - **模型文件路径**：`C:\Users\a\Desktop\资源` → 拼接生产库 CSV 文件名，得到完整生产库路径 - **目标路径**：`C:\Users\a\Desktop\资源\target` → 解析结果输出目录 ### 技能依赖检测 使用前检查所需技能是否已安装： | 依赖技能 | 用途 | 未安装时操作 | |---------|------|------------| | `google-drive` | 访问 Google Drive 下载合同 | 提示用户执行 `openclaw skills install google-drive`，或告知用户提供本地文件 | | `bali-resource-import` | 本技能（自身） | 提示用户提供正确路径 | | `pdfplumber` / `PyMuPDF` | PDF 文本提取 | pip 安装 `pdfplumber` | **缺失技能时的话术模板：** ``` [技能缺失] 解析入库需要先安装 google-drive 技能。 请在终端执行：openclaw skills install google-drive 安装完成后请重新发送任务。 ``` ## 使用流程 ### Step 0：安装依赖 ```bash # 安装 Google Drive 技能（如需要） openclaw skills install google-drive # 安装 PDF 解析库 pip install pdfplumber ``` ### Step 1：下载合同文件 **Google Drive 来源（仅下载合同文件）：** 1. 调用 `google-drive` 技能，传入 Drive URL，列出所有文件 2. **过滤合同文件**（非合同文件不下载）： - MIME 类型为 `application/pdf` 或 `application/vnd.openxmlformats-officedocument.wordprocessingml.document` - 文件名包含合同相关关键词：`合同`、`Contract`、`contract`、`Agreement`、`agreement`、`协议` - 如无法判断，一律下载，由 Step 3 解析时判断资源类型 3. 下载匹配文件到**目标路径** 4. 返回下载文件列表供后续使用 **本地来源**：从提示词中提取本地路径，定位 PDF/Word 文件。 **⚠️ 所有路径均为动态变量，不写死。** ### Step 2：读取生产库表头 **⚠️ 必须先读取目标 CSV 的实际表头和 encoding，再生成数据行。** ```python import csv def read_production_csv(path): for enc in ['utf-8-sig', 'gbk', 'utf-8', 'latin1']: try: with open(path, 'r', encoding=enc) as f: headers = next(csv.reader(f)) return headers, enc except UnicodeDecodeError: continue raise ValueError(f"无法读取 {path}") # 示例 headers, enc = read_production_csv(r"C:\Users\a\Desktop\资源\活动标准化完整版_含人群标签与特色标签_V4.csv") # headers = ['contract_id (合同编号)', 'activitynameen (活动英文名)', ...] # ⚠️ 表头格式为 "fieldname (中文说明)"，提取时用 split(' (')[0] 取英文字段名 ``` ### Step 3：解析合同内容 按合同内容识别资源类型： | 资源类型 | 判断关键词 | 核心价格字段 | |---------|----------|------------| | 酒店 | 房型、床型、早餐、入住、淡旺季 | 淡季价格_IDR / 旺季价格_IDR | | 车辆 | 车型、半天、全天、司机、油费 | 半天价格 / 全天价格 | | 景点 | 门票、开放时间、GPS、难度 | 门票-成人IDR | | 活动 | 时长、含保险、含交通、教练语言 | 成人IDR价格 | | SPA | 疗法、套餐、开放时间 | 成人IDR价格 | | 俱乐部 | 最低消费、入场费、时段政策 | 入场/门票费用 | | 餐厅 | 菜系、套餐、预约 | 成人IDR价格 | | 下午茶 | 套餐类型、成人儿童价 | 成人IDR价格 | ### Step 4：数据提取规则 **提取原则（按优先级）：** | 优先级 | 来源 | 处理方式 | |--------|------|---------| | 1 | 合同原文 | 直接使用，如实写入字段 | | 2 | 公开网络信息（如 GPS 坐标） | 可填写，标注来源 | | — | 合同无数据 | 填写"合同未提供"，不得留空 | | — | 编造数据 | 严禁 | **字段格式规范：** | 字段类型 | 格式示例 | |---------|---------| | 日期期间 | `2024-07-01 至 2024-08-31`（不用英文月份） | | 星级 | `5星`（带"星"字，非纯数字） | | 价格 | 纯数字，不含货币符号（如 `3581500` 而非 `IDR 3,581,500`） | | 汇率参考 | 1 CNY ≈ 2,100 IDR；1 USD ≈ 15,000 IDR | ### Step 5：三层标签体系 每条资源必须标注三层标签： **人群标签（第一层）：** `#家庭 #亲子 #情侣 #商务 #蜜月 #年轻人 #老年人 #单身` **风格标签（第二层）：** `#豪华 #经济型 #浪漫 #休闲 #刺激 #度假村风格 #自然 #文化` **特色标签（第三层）：** 按资源类型选用，详见下方各资源标签表。 ### Step 6：写入 CSV **⚠️ 关键规则：** 1. 新增数据的字段名必须与生产库表头完全一致 2. encoding 必须与生产库一致（通常 activity 用 `gbk`，hotel 用 `utf-8-sig`） 3. 写入模式为**追加**（append），不覆盖原有数据 ```python import csv, os def append_to_production(prod_path, data_rows, enc): """追加数据到生产库 CSV""" with open(prod_path, 'r', encoding=enc, newline='') as f: prod_rows = list(csv.reader(f)) prod_headers = prod_rows[0] # ['fieldname (中文说明)', ...] prod_fieldnames = [h.split(' (')[0] for h in prod_headers] # ['fieldname', ...] with open(prod_path, 'w', encoding=enc, newline='') as f: writer = csv.writer(f) writer.writerow(prod_headers) # 保留原有表头 writer.writerows(prod_rows[1:]) # 保留原有数据 for row in data_rows: # 追加新数据 out_row = [row.get(fn, '') for fn in prod_fieldnames] writer.writerow(out_row) ``` **直接追加示例（不依赖 append_to_csv.py）：** ```python # BOUNTY Cruises → 活动库 append_to_production( r"C:\Users\a\Desktop\资源\活动标准化完整版_含人群标签与特色标签_V4.csv", bounty_rows, # list of dict 'gbk' ) # Maya Ubud → 酒店库 append_to_production( r"C:\Users\a\Desktop\资源\巴厘岛酒店资源库_2026标准版_V4_FINAL.csv", maya_rows, 'utf-8-sig' ) ``` ### Step 7：输出报告 入库完成后，**向用户报告（路径为实际从提示词中提取的值）**： ``` ✅ 入库完成 ━━━━━━━━━━━━━━━ 合同文件：BOUNTY Cruises - March 2026.pdf / Maya Ubud Contract Valid 31 Mar 2025.pdf 资源类型：活动 × 4条 + 酒店 × 4条 目标库： 活动库：{模型文件路径}\活动标准化完整版_含人群标签与特色标签_V4.csv → +4 条 酒店库：{模型文件路径}\巴厘岛酒店资源库_2026标准版_V4_FINAL.csv → +4 条 输出目录：{目标路径}\ 清理结果：原始合同 PDF + parsed_results.json 已删除 ``` ### Step 8：清理临时文件 **入库完成后，目标文件夹只保留结果 CSV 文件**，一律删除： | 文件类型 | 示例 | 删除原因 | |---------|------|---------| | 原始合同 PDF/Word（云盘下载） | `BOUNTY Cruises - March 2026.pdf` | 原始合同不留在输出目录 | | 解析中间文件 | `parsed_results.json` | 仅作中间处理使用，不交付用户 | | 任何非结果 CSV 的残留文件 | `xxx_out.csv` 以外的临时文件 | 避免输出目录污染 | **保留文件：** ``` {目标路径}\ ├── BountyCruises活动数据_YYYYMMDD.csv ← 新增的活动数据（追加到生产库） └── MayaUbud酒店数据_YYYYMMDD.csv ← 新增的酒店数据（追加到生产库） ``` ```python import os def cleanup_target_dir(target_dir, keep_prefixes=('活动数据', '酒店数据', '景点数据', 'SPA数据', '餐厅数据', '俱乐部数据', '下午茶数据', '车费数据')): """清理目标目录，保留结果CSV，删除其余所有文件""" deleted = [] for f in os.listdir(target_dir): full = os.path.join(target_dir, f) if os.path.isfile(full): # 保留带指定前缀的结果CSV if any(f.startswith(p) and f.endswith('.csv') for p in keep_prefixes): continue # 其余文件一律删除 os.remove(full) deleted.append(f) return deleted deleted = cleanup_target_dir(target_dir) for f in deleted: print(f"[清理] 已删除: {f}") ``` ## 8类资源 CSV 文件 | 资源类型 | CSV 文件名 | encoding | |---------|-----------|---------| | 酒店 | `巴厘岛酒店资源库_2026标准版_V4_FINAL.csv` | utf-8-sig | | 车辆 | `巴厘岛车费成本表-成本_2.10.csv` | gbk | | 景点 | `景点标准化完整版_含人群标签与特色标签_V4.csv` | gbk | | 活动 | `活动标准化完整版_含人群标签与特色标签_V4.csv` | gbk | | SPA | `SPA标准化完整版_含人群标签与特色标签_V4.csv` | gbk | | 俱乐部 | `俱乐部标准化完整版_含人群标签与特色标签_V4.csv` | gbk | | 餐厅 | `餐厅标准化完整版_含人群标签与特色标签_V4.csv` | gbk | | 下午茶 | `下午茶标准化完整版_含人群标签与特色标签_V4.csv` | gbk | ## 三层标签参考 ### 人群标签（所有资源） `#家庭 #亲子 #情侣 #商务 #蜜月 #年轻人 #老年人 #单身` ### 风格标签（所有资源） `#豪华 #经济型 #浪漫 #休闲 #刺激 #度假村风格 #自然 #文化` ### 特色标签（按资源类型） **酒店：** `#海景房 #私人泳池 #儿童俱乐部 #水疗中心 #私人海滩 #日落景观 #Infinity泳池 #热带雨林景观 #乌布丛林` **车辆：** `#中文司机 #婴儿座椅 #行李空间大 #全景天窗 #全天候服务 #机场接送 #VIP包车` **景点：** `#日落观赏点 #浮潜点 #摄影圣地 #稻田风光 #印度教寺庙 #亲子友好 #小众秘境` **活动：** `#潜水 #冲浪 #火山徒步 #漂流 #丛林探险 #文化体验 #极限运动 #浮潜 #深潜 #ATV #滑索` **SPA：** `#巴厘岛传统按摩 #四手按摩 #情侣SPA #热石按摩 #排毒疗程 #蜜月套餐 #草本疗法 #花香浴` **俱乐部：** `#DJ演出 #主题派对 #沙滩酒吧 #VIP包厢 #泳池派对 #现场乐队 #日落美景 #网红打卡` **餐厅：** `#海鲜烧烤 #海滩景观 #网红餐厅 #蜜月餐厅 #私人包间 #现场音乐 #米其林 #亚洲50佳 #稻田景观` **下午茶：** `#海景下午茶 #日落下午茶 #泳池下午茶 #花园下午茶 #闺蜜下午茶 #梯田景观 #绝美拍照点` ## 异常处理规范 | 异常类型 | 判断标准 | 处理方式 | |---------|---------|---------| | 资源过期 | 合同有效期 < 今天 | 备注"已过期"，可信度降B级 | | 价格缺失 | 核心价格字段为空 | 暂停入库，告警人工补充 | | 价格冲突 | 同资源价差 > 10% | 标红，暂停入库，等人工决策 | | CSV encoding 读取失败 | 所有 encoding 均报错 | 提示用户文件损坏或格式异常 | | 生产库文件被占用 | PermissionError | 提示用户关闭 Excel / Google Drive Desktop 等程序后再重试 | | 表头字段不匹配 | 新数据字段与生产库不一致 | 报错退出，不写入，列出缺失/多余字段 | | GPS缺失 | 坐标字段为空 | 备注"待补充"，不阻止入库 | | 标签缺失 | 三层标签不完整 | 按资源类型默认补充基础标签 | | 数据完整度低 | 字段完整度 < 60% | 可信度标C级，优先处理高完整度资源 | ## 参考文档 | 文档 | 路径 | |------|------| | 巴厘岛旅游资源标准化文档体系 | `references/巴厘岛旅游资源标准化文档体系.md` | | 8类特色标签快速查阅表 | `references/8类特色标签快速查阅表.md` |

path: evals/evals.json
content: {
  "skill_name": "bali-resource-import",
  "evals": [
    {
      "id": 1,
      "prompt": "帮我解析这份巴厘岛酒店供应商合同（PDF），提取酒店信息并入库",
      "expected_output": "应识别为酒店资源，提取房型/价格/政策等35字段，追加到酒店CSV库",
      "files": ["hotel_contract.pdf"]
    },
    {
      "id": 2,
      "prompt": "有一个蓝梦岛浮潜的供应商文档，帮我入库到活动库",
      "expected_output": "应识别为活动资源，提取48个字段，标注三层标签，写入活动CSV",
      "files": ["nusa_snorkeling.docx"]
    },
    {
      "id": 3,
      "prompt": "用户上传了一张餐厅图片，这是供应商拍的菜单照片，帮我OCR识别并入库",
      "expected_output": "应使用tesseract OCR提取文字，识别为餐厅资源，入库到餐厅CSV",
      "files": ["restaurant_menu.jpg"]
    },
    {
      "id": 4,
      "prompt": "有一份Google Drive链接，里面有多个资源类型的供应商文件，帮我全部解析入库",
      "expected_output": "应按资源类型分别入库，支持多类型文档混合处理",
      "files": ["drive_link.txt"]
    },
    {
      "id": 5,
      "prompt": "帮我入库这个SPA供应商，但价格字段缺失，怎么处理？",
      "expected_output": "应检测到价格缺失异常，暂停入库并告警人工补充",
      "files": ["spa_incomplete.xlsx"]
    }
  ]
}


path: scripts/broadcast_redis.py
content: #!/usr/bin/env python3
"""
Redis Stream 广播脚本 - 资源更新通知
用法: python broadcast_redis.py --type TYPE --id RESOURCE_ID --version VERSION --action ACTION
"""

import argparse
import os
import sys
from datetime import datetime


def broadcast_resource_update(resource_type, resource_id, version, update_type):
    """
    广播资源更新到 Redis Stream

    Args:
        resource_type: 资源类型 (hotel/car/attraction/activity/spa/club/restaurant/tea)
        resource_id: 资源唯一标识
        version: 版本号 (如 v1, v2)
        update_type: 更新类型 (INSERT/UPDATE/DELETE)
    """
    try:
        import redis
    except ImportError:
        print("[SKIP] redis not installed, broadcast skipped")
        return True  # graceful degradation, not a failure

    # Redis 连接配置
    REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
    REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
    REDIS_STREAM = "resource_stream"

    try:
        r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
        r.ping()  # 测试连接
    except redis.ConnectionError:
        print(f"[ERROR] Cannot connect to Redis ({REDIS_HOST}:{REDIS_PORT})")
        print("[ERROR] Make sure Redis is running, or set REDIS_HOST / REDIS_PORT env vars")
        return False

    message = {
        "resource_type": resource_type,
        "resource_id": str(resource_id),
        "version": str(version),
        "update_type": update_type,
        "timestamp": datetime.now().isoformat(),
        "source": "xingyue_agent"
    }

    try:
        stream_id = r.xadd(REDIS_STREAM, message)
        print(f"[OK] Broadcast sent")
        print(f"   Stream: {REDIS_STREAM}")
        print(f"   ID: {stream_id}")
        print(f"   资源类型: {resource_type}")
        print(f"   资源ID: {resource_id}")
        print(f"   版本: {version}")
        print(f"   操作: {update_type}")
        print(f"   时间: {message['timestamp']}")
        return True
    except Exception as e:
        print(f"[ERROR] Broadcast failed: {e}")
        return False


def main():
    parser = argparse.ArgumentParser(description="Redis Stream 资源更新广播")
    parser.add_argument("--type", "-t", required=True,
                        choices=["hotel", "car", "attraction", "activity", "spa", "club", "restaurant", "tea"],
                        help="资源类型")
    parser.add_argument("--id", "-i", required=True,
                        help="资源唯一标识")
    parser.add_argument("--version", "-v", default="v1",
                        help="版本号 (默认: v1)")
    parser.add_argument("--action", "-a", required=True,
                        choices=["INSERT", "UPDATE", "DELETE"],
                        help="更新操作类型")

    args = parser.parse_args()

    success = broadcast_resource_update(
        resource_type=args.type,
        resource_id=args.id,
        version=args.version,
        update_type=args.action
    )

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)