Skill: innoucon-blip/buyma-order-automation (version 1.0.0)

--- name: buyma-order-automation description: automate buyma order processing for regular daily runs and ad hoc order-range runs. use when chatgpt needs to access buyma in chrome, check or fill receipt memo numbers, download or use a provided buyma csv, write the tmazon order workbook, enrich rows from prior workbook history, and send the result by naver mail before a deadline or after an ad hoc request. stop immediately and notify by telegram with file attachment on buyma, csv, or mail failure. --- # Overview This skill supports two modes: 1. Regular daily draft creation - Start time can vary - Must ensure mail is sent by 08:30 local time - Orders up to 07:00 must be included - Orders from 07:00 to 08:00 should be included as much as feasible while still guaranteeing mail send by 08:30 - If any critical failure occurs, stop immediately and notify via Telegram 2. Ad hoc range creation - Triggered when the operator requests a specific order-number range - Example: 123450~123470 - Complete the draft for that range - Notify via Telegram immediately after completion - Send mail immediately after completion ## Core rules - Always use Chrome default profile for BUYMA and Naver Mail - Use the latest delivered workbook first; otherwise use the latest automation-created workbook - Start from the last order number in the most recently delivered file from the operator. If no new delivered file exists, continue from the last workbook previously created by the automation - If BUYMA CSV download fails, allow operator-provided CSV as fallback input - If mail sending fails, notify and attach the result file in Telegram fallback - Follow MEMORY.md and recent memory logs before acting ## Base file selection Select the base order workbook in this order: 1. latest file in `~/.openclaw/workspace/buyma_order/orders/incoming/` 2. latest file in `~/.openclaw/workspace/buyma_order/orders/current/` 3. `~/.openclaw/workspace/buyma_order/templates/tmazonORDERLIST_template.xlsx` ## Output file naming Always save output as: `tmazonORDERLISTYYMMDD_start-end.xlsx` Example: `tmazonORDERLIST260307_123450-123470.xlsx` ## Receipt memo numbering Always check memo state for target orders. - If memo already contains a valid 6-digit order number, do not rewrite it - If memo contains text or a number shorter than 6 digits, prepend the 6-digit order number before the existing memo content - In ad hoc range mode, always perform the memo check/input step See `references/memo-rules.md`. ## Workflow 1. Determine run mode: regular daily or ad hoc range 2. Select base workbook 3. Access BUYMA in Chrome default profile 4. Check and input receipt memo numbers for target orders 5. Download shipping CSV (or use provided CSV fallback) 6. Parse CSV using `scripts/parse_buyma_csv.py` 7. Build draft workbook using `scripts/build_order_sheet.py` 8. Fill F using Chrome auto-translated Korean product names (browser placeholder step) 9. Enrich I/J/M from prior workbook history using `scripts/enrich_from_history.py` 10. Validate output using `scripts/validate_output.py` 11. Save output using `scripts/compose_output_filename.py` 12. Send by Naver Mail in Chrome (browser placeholder step) 13. Update state using `scripts/update_state.py` 14. On BUYMA/CSV/mail failure, stop immediately and notify via Telegram with file attachment if available ## Browser placeholder steps This package intentionally leaves browser-only steps as placeholders: - BUYMA login and page navigation - Receipt memo editing in BUYMA UI - BUYMA CSV download click-flow - Product page Korean name capture from Chrome auto-translation - Naver Mail compose/send - Telegram file attachment send from browser/channel tool path Use these references: - `references/workflow.md` - `references/failure-rules.md` - `references/run-modes.md` ## Script entrypoints - `scripts/select_base_file.py` - `scripts/parse_buyma_csv.py` - `scripts/build_order_sheet.py` - `scripts/enrich_from_history.py` - `scripts/validate_output.py` - `scripts/compose_output_filename.py` - `scripts/update_state.py`

path: agents/openai.yaml
content: model: openai/gpt-4o
reasoning: medium


path: assets/README.txt
content: This is an executable skeleton skill.

Included as real code:
- base workbook selection
- CSV normalization
- workbook write/update
- history enrichment for I/J/M
- filename generation
- state update
- basic validation

Left as placeholders/procedural browser steps:
- BUYMA login and browsing
- BUYMA memo editing
- BUYMA CSV click-flow
- Chrome translated product-name extraction
- Naver Mail sending
- Telegram fallback send


path: references/column-mapping.md
content: # Column Mapping

## BUYMA CSV -> Order Sheet

- P 受注メモ -> B
- K 発送方法 -> D
  - startswith `その他 /` => `사가`
  - startswith `韓国郵便局 /` => `KP`
- C 価格 -> G
- L 色・サイズ -> K
- D 受注数 -> L
- M 連絡事項 -> N (translate to Korean outside these scripts if needed)
- O 名前（ローマ字） -> N
  - only if region is OKINAWA -> append `오키나와`
  - if N already has content, append with newline

## Product name
- Write F using Chrome auto-translated Korean product name as shown in browser

## History enrichment by F

Search historical rows using Korean product name in F.

### I/J
- If one match: copy I and J
- If multiple different values: join with newline and mark red

### M
- If historical value is `9부산` or `5신강`: keep as-is
- If historical value is `6노원`, `7마산`, `8울산`: write `화라`
- If multiple different values: join with newline and mark red


path: references/failure-rules.md
content: # Failure Rules

## Immediate stop conditions

- BUYMA access failure
- CSV download failure without fallback CSV
- Mail send failure

## Required response

Stop the run immediately and notify via Telegram.

Failure text format:
`BUYMA 자동화 실패 / 단계: {stage} / 파일 첨부`

## Telegram fallback

- Send failure summary
- Attach output file if it exists
- Include run mode and target range if known

## CSV download failure

- If operator later provides a BUYMA CSV file, resume using that file
- Mark run as `resumed-from-manual-csv` in logs/state

## Mail send failure

- Notify via Telegram immediately
- Attach output workbook in Telegram
- Preserve workbook in `orders/current/`


path: references/file-rules.md
content: # File Rules

## Working root
`~/.openclaw/workspace/buyma_order/`

## Directories

- `config/`
- `csv/`
- `orders/incoming/`
- `orders/current/`
- `orders/archive/`
- `templates/`
- `logs/`

## Filename rules

Always:
`tmazonORDERLISTYYMMDD_start-end.xlsx`

Example:
- `tmazonORDERLIST260307_123450-123470.xlsx`

## Base file priority

1. latest file in `orders/incoming/`
2. latest file in `orders/current/`
3. `templates/tmazonORDERLIST_template.xlsx`

## State file

`config/last_state.json`

Track:
- `last_order_number`
- `last_run_date`
- `last_file`
- `last_mail_status`
- `last_mode`
- `last_start_order`
- `last_end_order`


path: references/memo-rules.md
content: # Memo Rules

## Goal

Ensure every processed BUYMA order has a valid 6-digit order number in receipt memo.

## Rules

### Case 1: memo already contains a valid 6-digit order number
- Do not rewrite
- Treat as already numbered

### Case 2: memo contains text only
- Prepend the new 6-digit order number
- Preserve existing text after it

Example:
existing memo: VIP
new order no: 123457
result: 123457 VIP

### Case 3: memo contains a numeric value shorter than 6 digits
- Prepend the new 6-digit order number
- Preserve existing value after it

Example:
existing memo: 345
new order no: 123457
result: 123457 345

### Case 4: memo empty
- Write only the 6-digit order number

## Scope

Apply these rules in:
- regular daily run
- ad hoc range run


path: references/run-modes.md
content: # Run Modes

## Regular daily run

Purpose:
- Process newly available orders
- Guarantee mail sending by 08:30
- Include all orders up to 07:00 without fail
- Include as many orders from 07:00 to 08:00 as feasible while still guaranteeing mail before 08:30

Behavior:
- Start at any practical time before deadline
- Use the last known delivered workbook state
- Continue from the latest valid base workbook
- Always perform memo check/input for target orders
- Do not use an artificially early cutoff such as 01:00
- Use a dynamic practical cutoff close enough to the deadline to still finish save and mail send
- Stop immediately on BUYMA access failure, CSV failure, or mail failure
- Notify via Telegram with file attachment if output exists

## Ad hoc range run

Trigger:
- Operator asks for a specific order-number range
- Example: 123450~123470

Behavior:
- Use the specified range only
- Always perform memo check/input for that range
- Build workbook immediately
- Notify completion by Telegram
- Send mail immediately
- Filename must include date and range


path: references/workflow.md
content: # Workflow

1. Load the latest delivered workbook if available; otherwise load the latest automation-created workbook; otherwise load the template
2. Determine run mode: regular daily or ad hoc range
3. Determine target order range
4. Access BUYMA in Chrome default profile
5. Check and input receipt memo numbers for target orders
6. Download shipping CSV or accept a manual CSV fallback from the operator
7. Parse CSV with `scripts/parse_buyma_csv.py`
8. Build draft workbook with `scripts/build_order_sheet.py`
9. Fill F using Chrome auto-translated Korean product names
10. Enrich I/J/M from prior workbook history with `scripts/enrich_from_history.py`
11. Validate workbook with `scripts/validate_output.py`
12. Compose output name with `scripts/compose_output_filename.py`
13. Send by Naver Mail in Chrome with subject `buyma 주문서 초안 -YYMMDD (start-end)`
14. Update `last_state.json` with `scripts/update_state.py`
15. On BUYMA/CSV/mail failure, stop immediately and notify via Telegram with file attachment if available


path: scripts/apply_translated_names.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from collections import defaultdict
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.styles import Font

def normalize_order_no(value) -> str:
    if value is None:
        return ""
    s = str(value).strip()
    return s[:6] if len(s) >= 6 else s


def main() -> None:
    parser = argparse.ArgumentParser(description="Apply translated Korean product names to workbook by order number")
    parser.add_argument("--workbook", required=True, help="Workbook path")
    parser.add_argument("--json", required=True, help="JSON path with order_no/product_name_ko")
    parser.add_argument("--start-row", type=int, required=True, help="Start row of newly written rows")
    parser.add_argument("--end-row", type=int, required=True, help="End row of newly written rows")
    parser.add_argument("--sheet", help="Optional sheet name")
    args = parser.parse_args()

    workbook_path = Path(args.workbook).expanduser().resolve()
    json_path = Path(args.json).expanduser().resolve()

    items = json.loads(json_path.read_text(encoding="utf-8"))

    mapping = defaultdict(list)
    for item in items:
        order_no = normalize_order_no(item.get("order_no"))
        product_name_ko = str(item.get("product_name_ko", "")).strip()
        if order_no and product_name_ko:
            mapping[order_no].append(product_name_ko)

    wb = load_workbook(workbook_path)
    RED_FONT = Font(color="FF0000")
    ws = wb[args.sheet] if args.sheet else wb.active

    updated = 0
    missing = 0
    overrun = 0
    used_index = defaultdict(int)

    for row in range(args.start_row, args.end_row + 1):
        order_no = normalize_order_no(ws[f"B{row}"].value)
        if not order_no:
            continue

        names = mapping.get(order_no, [])
        idx = used_index[order_no]

        if not names:
            missing += 1
            continue

        if idx >= len(names):
            overrun += 1
            continue

        cell = ws[f"F{row}"]
        cell.value = names[idx]

        if len(names) > 1:
            cell.font = RED_FONT

        used_index[order_no] += 1
        updated += 1

    wb.save(workbook_path)
    print(json.dumps({
        "updated_rows": updated,
        "missing_rows": missing,
        "overrun_rows": overrun,
        "start_row": args.start_row,
        "end_row": args.end_row
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/build_order_sheet.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from openpyxl import load_workbook

TARGET_COLS = {
    "B": "order_no",
    "D": "ship_method",
    "F": "product_name_ko",
    "G": "price",
    "K": "option",
    "L": "qty",
    "N": "n_value",
}


def find_start_row(ws, min_row: int = 11) -> int:
    last_row = min_row - 1
    for row in range(min_row, ws.max_row + 1):
        v = ws[f"B{row}"].value
        if isinstance(v, int):
            last_row = row
        elif isinstance(v, str) and v.strip().isdigit():
            last_row = row
    return last_row + 1


def write_rows(workbook_path: Path, records: List[Dict[str, Any]], sheet_name: Optional[str], out_path: Path) -> int:
    wb = load_workbook(workbook_path)
    ws = wb[sheet_name] if sheet_name else wb.active
    row = find_start_row(ws)
    first_row = row
    for rec in records:
        for col, key in TARGET_COLS.items():
            ws[f"{col}{row}"] = rec.get(key, "")
        row += 1
    wb.save(out_path)
    return first_row


def main() -> None:
    parser = argparse.ArgumentParser(description="Write normalized BUYMA records into workbook")
    parser.add_argument("--base", required=True, help="Base workbook path")
    parser.add_argument("--records", required=True, help="Normalized JSON records path")
    parser.add_argument("--out", required=True, help="Output workbook path")
    parser.add_argument("--sheet", help="Optional target sheet name")
    args = parser.parse_args()

    records = json.loads(Path(args.records).expanduser().resolve().read_text(encoding="utf-8"))
    first_row = write_rows(
        Path(args.base).expanduser().resolve(),
        records,
        args.sheet,
        Path(args.out).expanduser().resolve(),
    )
    print(json.dumps({"first_written_row": first_row, "count": len(records)}, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/build_raw_names_json.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path

ORDER_RE = re.compile(r'(\d{6})')

def main() -> None:
    parser = argparse.ArgumentParser(description="Convert BUYMA raw text into structured JSON")
    parser.add_argument("--input", required=True)
    parser.add_argument("--out", required=True)
    args = parser.parse_args()

    input_path = Path(args.input).expanduser().resolve()
    out_path = Path(args.out).expanduser().resolve()

    lines = [line.strip() for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip()]
    if len(lines) % 2 != 0:
        raise SystemExit("Input line count must be even (2 lines per block).")

    rows = []
    for i in range(0, len(lines), 2):
        memo_line = lines[i]
        product_name_ja = lines[i + 1]
        m = ORDER_RE.search(memo_line)
        order_no = m.group(1) if m else ""
        rows.append({
            "order_no": order_no,
            "memo_line": memo_line,
            "product_name_ja": product_name_ja,
        })

    out_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps({"count": len(rows), "output": str(out_path)}, ensure_ascii=False))

if __name__ == "__main__":
    main()


path: scripts/build_translated_names_from_raw_json.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path

REPLACEMENTS = [
    ("[ノースフェイス]", "[노스페이스]"),
    ("[NIKE]", "[NIKE]"),
    ("[COYSEIO]", "[COYSEIO]"),
    ("[WOOALONG]", "[WOOALONG]"),

    ("ジャケット", "재킷"),
    ("バックパック", "백팩"),
    ("バッグ", "가방"),
    ("バケットバッグ", "버킷백"),
    ("ショルダー", "숄더"),
    ("トート", "토트"),
    ("クロスバッグ", "크로스백"),
    ("ジムサック", "짐색"),
    ("ベスト", "베스트"),
    ("フーディー", "후디"),
    ("ニット", "니트"),
    ("スウェットシャツ", "스웨트셔츠"),
    ("スウェット", "스웨트"),
    ("半袖 Tシャツ", "반소매 티셔츠"),
    ("半袖", "반소매"),
    ("Tシャツ", "티셔츠"),

    ("男女", "남녀"),
    ("女性", "여성"),
    ("レディース", "여성"),
    ("人気", "인기"),
    ("新作", "신작"),
    ("韓国", "한국"),
]

def translate_text(text: str) -> str:
    result = text
    for src, dst in REPLACEMENTS:
        result = result.replace(src, dst)
    return result

def main() -> None:
    parser = argparse.ArgumentParser(description="Build translated_names.json from raw_names.json")
    parser.add_argument("--input", required=True)
    parser.add_argument("--out", required=True)
    args = parser.parse_args()

    input_path = Path(args.input).expanduser().resolve()
    out_path = Path(args.out).expanduser().resolve()

    rows = json.loads(input_path.read_text(encoding="utf-8"))
    out = []

    for row in rows:
        order_no = str(row.get("order_no", "")).strip()
        product_name_ja = str(row.get("product_name_ja", "")).strip()
        if not order_no or not product_name_ja:
            continue
        out.append({
            "order_no": order_no,
            "product_name_ko": translate_text(product_name_ja)
        })

    out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps({"count": len(out), "output": str(out_path)}, ensure_ascii=False))

if __name__ == "__main__":
    main()


path: scripts/build_translated_names_json.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path

ORDER_RE = re.compile(r'(\d{6})')

def main() -> None:
    parser = argparse.ArgumentParser(description="Build translated_names.json from line-based text input")
    parser.add_argument("--input", required=True, help="Input text file path")
    parser.add_argument("--out", required=True, help="Output json path")
    args = parser.parse_args()

    input_path = Path(args.input).expanduser().resolve()
    out_path = Path(args.out).expanduser().resolve()

    lines = [line.strip() for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip()]

    result = []
    i = 0
    while i < len(lines) - 1:
        memo_line = lines[i]
        product_line = lines[i + 1]

        m = ORDER_RE.search(memo_line)
        if m:
            result.append({
                "order_no": m.group(1),
                "product_name_ko": product_line
            })
            i += 2
        else:
            i += 1

    out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps({
        "count": len(result),
        "output": str(out_path)
    }, ensure_ascii=False))

if __name__ == "__main__":
    main()


path: scripts/compose_output_filename.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
from datetime import datetime


def main() -> None:
    parser = argparse.ArgumentParser(description="Compose BUYMA output workbook filename")
    parser.add_argument("--date", help="YYMMDD. Defaults to today")
    parser.add_argument("--start", required=True, help="Start order number")
    parser.add_argument("--end", required=True, help="End order number")
    args = parser.parse_args()
    date_str = args.date or datetime.now().strftime("%y%m%d")
    print(f"tmazonORDERLIST{date_str}_{args.start}-{args.end}.xlsx")


if __name__ == "__main__":
    main()


path: scripts/enrich_from_history.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Dict, List, Set, Tuple
from openpyxl import load_workbook
from openpyxl.styles import Font

RED = "00FF0000"


def normalize(value) -> str:
    return str(value).strip() if value is not None else ""


def map_m_value(value: str) -> str:
    if value in {"9부산", "5신강"}:
        return value
    if value in {"6노원", "7마산", "8울산"}:
        return "화라"
    return ""


def collect_history(ws, until_row: int) -> Dict[str, Dict[str, Set[str]]]:
    hist: Dict[str, Dict[str, Set[str]]] = {}
    for row in range(2, until_row):
        f = normalize(ws[f"F{row}"].value)
        if not f:
            continue
        item = hist.setdefault(f, {"I": set(), "J": set(), "M": set()})
        i_val = normalize(ws[f"I{row}"].value)
        j_val = normalize(ws[f"J{row}"].value)
        m_val = map_m_value(normalize(ws[f"M{row}"].value))
        if i_val:
            item["I"].add(i_val)
        if j_val:
            item["J"].add(j_val)
        if m_val:
            item["M"].add(m_val)
    return hist


def set_joined(cell, values: Set[str]) -> None:
    if not values:
        return
    sorted_vals = sorted(values)
    cell.value = "\n".join(sorted_vals)
    if len(sorted_vals) > 1:
        cell.font = Font(color=RED)


def enrich(workbook_path: Path, start_row: int, end_row: int, sheet_name: str | None) -> Dict[str, int]:
    wb = load_workbook(workbook_path)
    ws = wb[sheet_name] if sheet_name else wb.active
    history = collect_history(ws, start_row)
    updated = 0
    multi = 0
    for row in range(start_row, end_row + 1):
        f = normalize(ws[f"F{row}"].value)
        if not f or f not in history:
            continue
        item = history[f]
        before = (ws[f"I{row}"].value, ws[f"J{row}"].value, ws[f"M{row}"].value)
        set_joined(ws[f"I{row}"], item["I"])
        set_joined(ws[f"J{row}"], item["J"])
        set_joined(ws[f"M{row}"], item["M"])
        after = (ws[f"I{row}"].value, ws[f"J{row}"].value, ws[f"M{row}"].value)
        if after != before:
            updated += 1
        if any(len(item[k]) > 1 for k in ("I", "J", "M")):
            multi += 1
    wb.save(workbook_path)
    return {"updated_rows": updated, "multi_match_rows": multi}


def main() -> None:
    parser = argparse.ArgumentParser(description="Enrich current workbook rows from historical rows")
    parser.add_argument("--workbook", required=True)
    parser.add_argument("--start-row", type=int, required=True)
    parser.add_argument("--end-row", type=int, required=True)
    parser.add_argument("--sheet")
    args = parser.parse_args()
    result = enrich(Path(args.workbook).expanduser().resolve(), args.start_row, args.end_row, args.sheet)
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/parse_buyma_csv.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import re
from pathlib import Path
from typing import Dict, List

FIELD_INDEX = {
    "product_name": 1,  # B
    "qty": 3,           # D
    "price": 2,         # C
    "ship_method": 10,  # K
    "option": 11,       # L
    "contact_note": 12, # M
    "name_roman": 14,   # O
    "memo": 15,         # P
    "region": 8,        # I (best-effort; region source can vary by export layout)
}


def map_ship_method(value: str) -> str:
    value = (value or "").strip()
    if value.startswith("その他 /"):
        return "사가"
    if value.startswith("韓国郵便局 /"):
        return "KP"
    return value


def normalize_note(existing: str, region: str, roman_name: str) -> str:
    parts = []
    existing = (existing or "").strip()
    if existing:
        parts.append(existing)
    if (region or "").strip().upper() == "OKINAWA":
        parts.append("오키나와")
    return "\n".join(parts)


def parse_csv(path: Path, encoding: str) -> List[Dict[str, str]]:
    rows: List[Dict[str, str]] = []
    with path.open("r", encoding=encoding, newline="") as f:
        reader = csv.reader(f)
        for raw in reader:
            if not raw:
                continue
            max_idx = max(FIELD_INDEX.values())
            if len(raw) <= max_idx:
                continue
            # skip probable header rows
            if raw[0].strip() in {"受注番号", "注文番号", "Order"}:
                continue

            memo_raw = (raw[FIELD_INDEX["memo"]] or "").strip()
            m = re.search(r'(\d{6})', memo_raw)
            order_no = m.group(1) if m else ""

            record = {
                "order_no": order_no,
                "memo_no": (raw[FIELD_INDEX["memo"]] or "").strip(),
                "ship_method_raw": (raw[FIELD_INDEX["ship_method"]] or "").strip(),
                "ship_method": map_ship_method(raw[FIELD_INDEX["ship_method"]]),
                "product_name_ko": (raw[FIELD_INDEX["product_name"]] or "").strip(),
                "price": (raw[FIELD_INDEX["price"]] or "").strip(),
                "option": (raw[FIELD_INDEX["option"]] or "").strip(),
                "qty": (raw[FIELD_INDEX["qty"]] or "").strip(),
                "contact_note_raw": (raw[FIELD_INDEX["contact_note"]] or "").strip(),
                "name_roman": (raw[FIELD_INDEX["name_roman"]] or "").strip(),
                "region": (raw[FIELD_INDEX["region"]] or "").strip(),
            }
            if record.get("memo_no") == "受注メモ" and record.get("ship_method_raw") == "発送方法":
                continue
            record["n_value"] = normalize_note(record["contact_note_raw"], record["region"], record["name_roman"])
            rows.append(record)
    return rows


def main() -> None:
    parser = argparse.ArgumentParser(description="Parse BUYMA CSV into normalized JSON")
    parser.add_argument("csv_path", help="Path to BUYMA CSV")
    parser.add_argument("--encoding", default="utf-8-sig", help="CSV encoding (default: utf-8-sig)")
    parser.add_argument("--out", help="Optional JSON output path")
    args = parser.parse_args()

    data = parse_csv(Path(args.csv_path).expanduser().resolve(), args.encoding)
    if args.out:
        Path(args.out).expanduser().resolve().write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    else:
        print(json.dumps(data, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/select_base_file.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Optional

PREFERRED_EXTS = (".xlsx", ".xlsm", ".xls")


def latest_excel(path: Path) -> Optional[Path]:
    if not path.exists():
        return None
    candidates = [p for p in path.iterdir() if p.is_file() and p.suffix.lower() in PREFERRED_EXTS]
    if not candidates:
        return None
    candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
    return candidates[0]


def select_base(root: Path) -> Path:
    incoming = latest_excel(root / "orders" / "incoming")
    if incoming:
        return incoming
    current = latest_excel(root / "orders" / "current")
    if current:
        return current
    template = root / "templates" / "tmazonORDERLIST_template.xlsx"
    if template.exists():
        return template
    raise FileNotFoundError("No base workbook found in incoming/current/templates")


def main() -> None:
    parser = argparse.ArgumentParser(description="Select BUYMA base workbook")
    parser.add_argument("--root", required=True, help="buyma_order root path")
    args = parser.parse_args()
    root = Path(args.root).expanduser().resolve()
    print(select_base(root))


if __name__ == "__main__":
    main()


path: scripts/update_state.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path


def main() -> None:
    parser = argparse.ArgumentParser(description="Update buyma last_state.json")
    parser.add_argument("--state", required=True)
    parser.add_argument("--last-order-number", type=int, required=True)
    parser.add_argument("--last-file", required=True)
    parser.add_argument("--last-mail-status", required=True)
    parser.add_argument("--last-mode", required=True)
    parser.add_argument("--last-start-order", type=int, required=True)
    parser.add_argument("--last-end-order", type=int, required=True)
    args = parser.parse_args()

    state = {
        "last_order_number": args.last_order_number,
        "last_run_date": datetime.now().strftime("%Y-%m-%d"),
        "last_file": args.last_file,
        "last_mail_status": args.last_mail_status,
        "last_mode": args.last_mode,
        "last_start_order": args.last_start_order,
        "last_end_order": args.last_end_order,
    }
    path = Path(args.state).expanduser().resolve()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(state, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/validate_buyma_raw_text.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
from collections import Counter
from pathlib import Path

ORDER_RE = re.compile(r'(\d{6})')


def main() -> None:
    parser = argparse.ArgumentParser(description="Validate BUYMA raw translated text format")
    parser.add_argument("--input", required=True, help="Path to buyma_translated_raw.txt")
    args = parser.parse_args()

    input_path = Path(args.input).expanduser().resolve()
    lines = [line.strip() for line in input_path.read_text(encoding="utf-8").splitlines() if line.strip()]

    result = {
        "input": str(input_path),
        "nonempty_line_count": len(lines),
        "is_even_line_count": len(lines) % 2 == 0,
        "pair_count": len(lines) // 2,
        "valid_order_count": 0,
        "missing_order_count": 0,
        "duplicate_order_count": 0,
        "duplicate_orders": {},
        "preview": [],
        "warnings": [],
    }

    if len(lines) % 2 != 0:
        result["warnings"].append("Line count is odd. Expected 2 lines per order block.")

    extracted_orders = []

    for i in range(0, len(lines) - 1, 2):
        memo_line = lines[i]
        product_line = lines[i + 1]

        m = ORDER_RE.search(memo_line)
        order_no = m.group(1) if m else ""

        if order_no:
            result["valid_order_count"] += 1
            extracted_orders.append(order_no)
        else:
            result["missing_order_count"] += 1

        if len(result["preview"]) < 10:
            result["preview"].append({
                "block_index": (i // 2) + 1,
                "memo_line": memo_line,
                "order_no": order_no,
                "product_name_ko": product_line,
            })

    counter = Counter(extracted_orders)
    dupes = {k: v for k, v in counter.items() if v > 1}
    result["duplicate_order_count"] = len(dupes)
    result["duplicate_orders"] = dupes

    if result["missing_order_count"] > 0:
        result["warnings"].append(f"{result['missing_order_count']} block(s) have no 6-digit order number.")

    if result["duplicate_order_count"] > 0:
        result["warnings"].append(f"{result['duplicate_order_count']} duplicate order number(s) found.")

    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/validate_output.py
content: #!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Dict, List
from openpyxl import load_workbook

REQUIRED_COLS = ["B", "D", "F", "G", "K", "L"]


def validate(workbook_path: Path, start_row: int, end_row: int, sheet_name: str | None) -> Dict[str, object]:
    wb = load_workbook(workbook_path)
    ws = wb[sheet_name] if sheet_name else wb.active
    blanks: List[Dict[str, object]] = []
    for row in range(start_row, end_row + 1):
        missing = [col for col in REQUIRED_COLS if ws[f"{col}{row}"].value in (None, "")]
        if missing:
            blanks.append({"row": row, "missing": missing})
    return {
        "ok": len(blanks) == 0,
        "checked_rows": max(0, end_row - start_row + 1),
        "blank_rows": blanks,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Validate required workbook cells")
    parser.add_argument("--workbook", required=True)
    parser.add_argument("--start-row", type=int, required=True)
    parser.add_argument("--end-row", type=int, required=True)
    parser.add_argument("--sheet")
    args = parser.parse_args()
    result = validate(Path(args.workbook).expanduser().resolve(), args.start_row, args.end_row, args.sheet)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


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