浏览代码

Initial commit

Krystic Cong 1 月之前
父节点
当前提交
c7d35c8a73
共有 13 个文件被更改,包括 1439 次插入0 次删除
  1. 二进制
      .DS_Store
  2. 二进制
      10月29日更新.docx
  3. 6 0
      11月更新說明_繁體_英文.txt
  4. 500 0
      bulk_send_keys.py
  5. 二进制
      curator_requests.xlsx
  6. 二进制
      curator_requests.xlsx.bak.xlsx
  7. 7 0
      curator_state.json
  8. 30 0
      email_template.html
  9. 681 0
      imap_curator_export.py
  10. 196 0
      imap_flags_inspector.py
  11. 3 0
      requirements.txt
  12. 二进制
      send_log.xlsx
  13. 16 0
      steam_key.txt

二进制
.DS_Store


二进制
10月29日更新.docx


+ 6 - 0
11月更新說明_繁體_英文.txt

@@ -0,0 +1,6 @@
+=== 繁體中文 ===
+[h2][img src="{STEAM_CLAN_IMAGE}/45705479/edbf8876383473610d200bbd2a90fbd7c76213f5.png"][/img][/h2][h2]更新內容[/h2][olist][*][p]優化[b]地圖事件解鎖機制[/b][/p][list][*][p]降低了地圖中解鎖女孩事件所需的好感度門檻[/p][/*][*][p]點擊未解鎖女孩頭像時,現在會顯示【好感度不足】的提示文字[/p][/*][*][p]點擊地圖中已完成的女孩事件頭像時,現可重新遊玩該事件[/p][/*][/list][/*][*][p]調整所有[b]連擊 QTE 的文字描述[/b][/p][list][*][p]為所有連擊 QTE 增加了「連續點擊」提示字樣,使操作類型更直觀、易辨識[/p][/*][/list][/*][*][p]優化[b]第九章樹狀圖的可回溯狀態[/b][/p][list][*][p]原位於第九章末尾、不可回溯的關卡現已開放回溯功能,方便玩家回顧劇情[/p][/*][/list][/*][*][p]改進[b]限時選項關卡體驗[/b][/p][list][*][p]優化了部分限時選項設定,使玩家能更直觀地透過選項進入隱藏片段[/p][/*][/list][/*][*][p][b]效能優化[/b][/p][list][*][p]針對特定條件下可能出現的卡頓情況進行了進一步優化,提升整體流暢度[/p][/*][/list][/*][/olist][hr][/hr][h2]後續計劃[/h2][olist][*][p]調整玩家[b]反饋觀感不佳的影片內容[/b][/p][/*][*][p]優化 [b]「樂子人」 與 「鐵公雞」 結局[/b]的達成路徑[/p][/*][*][p]持續優化[b]影片播放邏輯[/b],進一步減少卡頓與閃退問題[/p][/*][/olist][hr][/hr][h2]反饋管道[/h2][list][*][p][b]問卷反饋[/b]:[url="https://docs.qq.com/form/page/DTFhKS2htTENrVFRF"]https://docs.qq.com/form/page/DTFhKS2htTENrVFRF[/url][/p][/*][*][p][b]QQ 群[/b]:群① 682438722 | 群② 615769890[/p][/*][*][p][b]抖音交流群[/b]:群① 848927145296 | 群② 361369778538[/p][/*][*][p][b]客服信箱[/b]:[url="https://support@such-one.com"]support@such-one.com[/url][/p][/*][/list]
+
+
+=== English ===
+[h2][img src="{STEAM_CLAN_IMAGE}/45705479/edbf8876383473610d200bbd2a90fbd7c76213f5.png"][/img][/h2][h2]Update Notes[/h2][olist][*][p]Optimized [b]Map Event Unlock System[/b][/p][list][*][p]Reduced the affection level required to unlock girl events on the map.[/p][/*][*][p]A “Not enough affection” message now appears when clicking a locked girl icon.[/p][/*][*][p]You can now replay completed girl events by clicking their icons on the map.[/p][/*][/list][/*][*][p]Adjusted all [b]Combo QTE Text Descriptions[/b][/p][list][*][p]Added “Repeated Tap” hints to all combo QTEs for clearer operation types.[/p][/*][/list][/*][*][p]Improved [b]Chapter 9 Node Traceback[/b][/p][list][*][p]Late-stage Chapter 9 levels can now be revisited for story review.[/p][/*][/list][/*][*][p]Enhanced [b]Timed Choice Stages[/b][/p][list][*][p]Adjusted limited-time options for smoother access to hidden scenes.[/p][/*][/list][/*][*][p][b]Performance Optimization[/b][/p][list][*][p]Further improved stability under specific conditions to reduce stuttering.[/p][/*][/list][/*][/olist][hr][/hr][h2]Future Plans[/h2][olist][*][p]Adjust video content with poor player feedback.[/p][/*][*][p]Optimize routes to the “Jokester” and “Penny Pincher” endings.[/p][/*][*][p]Continue improving video playback logic to reduce lag and crashes.[/p][/*][/olist][hr][/hr][h2]Feedback Channels[/h2][list][*][p][b]Survey[/b]: [url="https://docs.qq.com/form/page/DTFhKS2htTENrVFRF"]https://docs.qq.com/form/page/DTFhKS2htTENrVFRF[/url][/p][/*][*][p][b]QQ Groups[/b]: Group① 682438722 | Group② 615769890[/p][/*][*][p][b]Douyin Groups[/b]: Group① 848927145296 | Group② 361369778538[/p][/*][*][p][b]Support Email[/b]: [url="https://support@such-one.com"]support@such-one.com[/url][/p][/*][/list]

+ 500 - 0
bulk_send_keys.py

@@ -0,0 +1,500 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+bulk_send_keys.py
+-----------------
+Read an Excel exported by imap_curator_export.py, allocate Steam keys from a TXT pool (one per line),
+and send HTML emails to curators using a fixed template.
+
+This version:
+- Logs ONE ROW PER KEY (not per email).
+- Log column order (front):
+    1) No.
+    2) Send Date (Beijing, YYYY-MM-DD)
+    3) Channel (auto from social link; default "Steam")
+    4) Curator/Name
+    5) Purpose (fixed "评测")
+    6) Social Link
+- Other columns kept (and adjusted for per-key row):
+    Key, Mailbox Key, To, Requested Key Count, Subject, Status, Sent At, Test Mode, Actual Recipient
+- Subject uses: --subject + " RE: " + original email subject (if present)
+- Test mode does NOT consume keys; dry-run does not send/consume.
+- Optional external HTML template via --template (fallback to built-in).
+
+Usage examples:
+  python bulk_send_keys.py --excel curator_requests.xlsx --keys key_pool.txt --out send_log.xlsx \
+      --subject "Steam Keys for Such A Guy" --dry-run --limit 2
+
+  python bulk_send_keys.py --excel curator_requests.xlsx --keys key_pool.txt --out send_log.xlsx \
+      --subject "Steam Keys for Such A Guy" --test --test-email krystic@such-one.com --limit 1
+"""
+
+import os
+import sys
+import argparse
+import smtplib
+import ssl
+import imaplib
+from email.message import EmailMessage
+from typing import List, Dict, Any, Optional, Tuple
+import pandas as pd
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+# Built-in fallback template (can be overridden by --template file)
+FALLBACK_TEMPLATE = """\
+<html>
+  <body>
+    <p>Hi <b>{curator_name}</b>,</p>
+    <p>Thank you for your interest in <b>Such A Guy</b>!
+    We’re happy to provide you with <b>{key_num} Steam key(s)</b>:</p>
+    <p><b>{keys_block}</b></p>
+    <p><b>Included materials:</b><br/>
+    Press kit (capsules, screenshots, short trailer):<br/>
+    <a href="https://drive.google.com/drive/folders/15h5IQWy0AD1TFBz2Jgd2lNp9CkA3szY8?usp=sharing">Google Drive Press Kit</a><br/>
+    Review notes (features, estimated length, content warnings):<br/>
+    <a href="https://docs.google.com/document/d/1aTPTiDxCbsd3Ie4tNK47LUcYYzIZw5Yf4nveJG4KT7s/edit?usp=sharing">Google Docs Review Notes</a></p>
+    <p>We’d really appreciate your honest impressions after you’ve tried the game —
+    both on your <b>Steam Curator page</b> and as a <b>store review</b> on Steam.
+    If you enjoyed the experience, even a short recommendation would help more players discover it. 💫</p>
+    <p>Best,<br/><b>Krystic</b><br/>SUCH ONE STUDIO</p>
+  </body>
+</html>
+"""
+
+def load_template(path: Optional[str]) -> str:
+    if path and os.path.exists(path):
+        with open(path, "r", encoding="utf-8") as f:
+            return f.read()
+    return FALLBACK_TEMPLATE
+
+def load_keys(path: str) -> List[str]:
+    with open(path, "r", encoding="utf-8") as f:
+        keys = [line.strip() for line in f if line.strip()]
+    return keys
+
+def save_remaining_keys(path: str, keys: List[str]) -> None:
+    tmp = path + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        for k in keys:
+            f.write(k + "\n")
+    os.replace(tmp, path)
+
+def render_email_html(template_html: str, curator_name: str, key_num: int, keys: List[str]) -> str:
+    keys_lines = "<br/>\n".join([k for k in keys])
+    return template_html.format(curator_name=curator_name or "there",
+                                key_num=key_num,
+                                keys_block=keys_lines)
+
+def send_email(smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str,
+               from_name: str, from_email: str, replyto: str,
+               to_email: str, subject: str, html: str, dry_run: bool = False) -> None:
+    if dry_run:
+        print(f"\n--- DRY RUN (no send) ---\nTO: {to_email}\nSUBJECT: {subject}\nHTML:\n{html}\n-------------------------\n")
+        return
+    msg = EmailMessage()
+    msg["Subject"] = subject
+    msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
+    msg["To"] = to_email
+    msg["In-Reply-To"] = replyto
+    msg.set_content("HTML email - please view in a mail client that supports HTML.")
+    msg.add_alternative(html, subtype="html")
+
+    context = ssl.create_default_context()
+    with smtplib.SMTP(smtp_host, smtp_port) as server:
+        server.ehlo()
+        if smtp_port == 587:
+            server.starttls(context=context)
+            server.ehlo()
+        server.login(smtp_user, smtp_pass)
+        server.send_message(msg)
+
+def parse_channel_and_link(links_str: str) -> Tuple[str, str]:
+    """Return (channel, link) based on first recognizable URL; default ('Steam','')."""
+    if not isinstance(links_str, str) or not links_str.strip():
+        return ("Steam", "")
+    # Take first URL if comma-separated
+    first = links_str.split(",")[0].strip()
+    low = first.lower()
+    mapping = [
+        ("store.steampowered.com/curator", "Steam"),
+        ("steamcommunity.com", "Steam"),
+        ("youtu.be", "YouTube"),
+        ("youtube.com", "YouTube"),
+        ("x.com", "Twitter"),
+        ("twitter.com", "Twitter"),
+        ("twitch.tv", "Twitch"),
+        ("discord.gg", "Discord"),
+        ("discord.com", "Discord"),
+        ("facebook.com", "Facebook"),
+        ("instagram.com", "Instagram"),
+        ("tiktok.com", "TikTok"),
+        ("bilibili.com", "Bilibili"),
+        ("weibo.com", "Weibo"),
+    ]
+    for needle, label in mapping:
+        if needle in low:
+            return (label, first)
+    return ("Steam", first)  # default channel name
+
+def _norm_uid(u) -> str:
+    if isinstance(u, (bytes, bytearray)):
+        return u.decode("utf-8", errors="ignore").strip()
+    return str(u).strip()
+
+def _chunked(seq, n):
+    for i in range(0, len(seq), n):
+        yield seq[i:i+n]
+
+def imap_mark_answered_batch(host: str, port: int, user: str, pwd: str, mailbox: str,
+                             uids: list[str], batch_size: int = 500) -> int:
+    """把一批 UID 标记为 \\Answered,返回成功提交的数量。"""
+    uids = [_norm_uid(u) for u in uids if _norm_uid(u)]
+    if not uids:
+        return 0
+
+    M = imaplib.IMAP4_SSL(host, port)
+    M.login(user, pwd)
+    typ, _ = M.select(mailbox, readonly=False)
+    if typ != "OK":
+        try:
+            M.close()
+        except Exception:
+            pass
+        M.logout()
+        return 0
+
+    total_ok = 0
+    for batch in _chunked(uids, batch_size):
+        seqset = ",".join(batch)  # e.g. "444,445,446"
+        typ1, _ = M.uid("STORE", seqset, "+FLAGS.SILENT", r"(\Answered)")
+        if typ1 != "OK":
+            typ2, resp2 = M.uid("STORE", seqset, "+FLAGS", r"(\Answered)")
+            if typ2 == "OK":
+                total_ok += len(batch)
+            else:
+                # 逐封回退
+                for uid in batch:
+                    t3, _ = M.uid("STORE", uid, "+FLAGS.SILENT", r"(\Answered)")
+                    if t3 == "OK":
+                        total_ok += 1
+                    else:
+                        t4, _ = M.uid("STORE", uid, "+FLAGS", r"(\Answered)")
+                        if t4 == "OK":
+                            total_ok += 1
+        else:
+            total_ok += len(batch)
+
+    try:
+        M.close()
+    except Exception:
+        pass
+    M.logout()
+    return total_ok
+
+def main():
+    parser = argparse.ArgumentParser(description="Bulk send Steam keys to curators from Excel (one row per key).")
+    parser.add_argument("--excel", required=True, help="Path to input Excel (curator_requests.xlsx).")
+    parser.add_argument("--keys", required=True, help="Path to key pool TXT (one key per line).")
+    parser.add_argument("--out", default="send_log.xlsx", help="Path to output Excel log (per key rows).")
+    parser.add_argument("--subject", required=True, help="Base email subject (will prefix ' RE: original subject').")
+    parser.add_argument("--template", default="email_template.html", help="Path to HTML email template file (optional).")
+    parser.add_argument("--limit", type=int, default=None, help="Max rows to process from Excel.")
+    parser.add_argument("--dry-run", action="store_true", help="Render emails only; do not send.")
+    parser.add_argument("--test", action="store_true", help="Send to test address instead of recipients.")
+    parser.add_argument("--test-email", default=os.environ.get("TEST_EMAIL", ""), help="Test recipient (with --test).")
+    parser.add_argument("--skip-sent", action="store_true", help="Skip rows already present (by UID) in the output log.")
+    parser.add_argument("--no-consume", action="store_true", help="Do not modify key pool file (do not remove used keys).")
+
+    # SMTP config (env or CLI)
+    parser.add_argument("--smtp-host", default=os.environ.get("SMTP_HOST", ""))
+    parser.add_argument("--smtp-port", type=int, default=int(os.environ.get("SMTP_PORT", "587")))
+    parser.add_argument("--smtp-user", default=os.environ.get("SMTP_USER", ""))
+    parser.add_argument("--smtp-pass", default=os.environ.get("SMTP_PASS", ""))
+    parser.add_argument("--from-name", default=os.environ.get("FROM_NAME", "Krystic"))
+    parser.add_argument("--from-email", default=os.environ.get("FROM_EMAIL", os.environ.get("SMTP_USER", "")))
+
+    # IMAP config for optional marking as answered
+    parser.add_argument("--mark-answered", action="store_true",
+    help="After real sends, mark original messages as \\Answered via IMAP.")
+    parser.add_argument("--imap-host", default=os.environ.get("IMAP_HOST", ""))
+    parser.add_argument("--imap-port", type=int, default=int(os.environ.get("IMAP_PORT", "993")))
+    parser.add_argument("--imap-user", default=os.environ.get("EMAIL_USER", os.environ.get("SMTP_USER","")))
+    parser.add_argument("--imap-pass", default=os.environ.get("EMAIL_PASS", os.environ.get("SMTP_PASS", "")))
+    parser.add_argument("--imap-mailbox", default=os.environ.get("MAILBOX", "INBOX"))
+
+    args = parser.parse_args()
+
+    # Validate SMTP when not dry-run
+    if not args.dry_run:
+        for vname in ["smtp_host", "smtp_user", "smtp_pass", "from_email"]:
+            if not getattr(args, vname):
+                print(f"ERROR: Missing SMTP config --{vname.replace('_','-')} (or env var). Use --dry-run to preview.", file=sys.stderr)
+                sys.exit(2)
+
+    # Load template (external file if exists, otherwise fallback)
+    template_html = load_template(args.template)
+
+    # Load Excel
+    df = pd.read_excel(args.excel, sheet_name=0)
+    required_cols = ["Mailbox Key", "Email", "Curator/Name", "Requested Key Count", "Subject", "Curator/Social Links"]
+    for c in required_cols:
+        if c not in df.columns:
+            print(f"ERROR: Excel missing column: {c}", file=sys.stderr)
+            sys.exit(3)
+
+    # Load existing log to support --skip-sent (by UID)
+    sent_uids = set()
+    if args.skip_sent and os.path.exists(args.out):
+        try:
+            logdf = pd.read_excel(args.out, sheet_name=0)
+            if "Mailbox Key" in logdf.columns:
+                sent_uids = set(str(x) for x in logdf["Mailbox Key"].astype(str).tolist())
+        except Exception:
+            pass
+
+    # Load key pool
+    pool = load_keys(args.keys)
+
+    # Prepare per-key logging
+    log_rows: List[Dict[str, Any]] = []
+    row_no = 1
+    processed = 0
+
+    # ===== 进度与汇总统计 =====
+    # 估算计划处理的“邮件行”总数(考虑 --limit 与 --skip-sent)
+    if args.limit is not None:
+        total_target = min(len(df), args.limit)
+    else:
+        total_target = len(df)
+    if args.skip_sent and os.path.exists(args.out):
+        try:
+            # 粗略估算,已发过的行会被跳过(只是估算,实际略有出入也没关系)
+            total_target = max(0, total_target - len(sent_uids))
+        except Exception:
+            pass
+
+    attempt_rows = 0        # 实际尝试发送的“邮件行”(有成功分配到 key 才算一次尝试)
+    emails_ok = 0           # 发送成功(SENT 或 SENT_TEST)
+    emails_fail = 0         # 失败/跳过(ERROR 或 SKIPPED_NO_KEYS 等)
+    keys_assigned_total = 0 # 实际分配(写进邮件里的)key 数(dry-run/test 也会统计)
+
+    uids_to_mark_answered: list[str] = []
+
+    # Iterate over Excel rows (one email row)
+    for idx, row in df.iterrows():
+        try:
+            uid = str(row.get("Mailbox Key", "")).strip()
+            email_to = str(row.get("Email", "")).strip()
+
+            name_val = row.get("Curator/Name", "")
+            if pd.isna(name_val) or not str(name_val).strip():
+                curator_name = "Curator"
+            else:
+                curator_name = str(name_val).strip()
+            
+            if args.test:
+                curator_name = curator_name + "(" + email_to + ")"
+
+            req_num = row.get("Requested Key Count")
+            try:
+                key_num = int(req_num) if pd.notna(req_num) else 2
+            except Exception:
+                key_num = 2
+            if key_num <= 0:
+                key_num = 2
+
+            if args.skip_sent and uid and uid in sent_uids:
+                continue
+
+            if args.limit is not None and processed >= args.limit:
+                break
+
+            # Channel & link detection (robust against NaN)
+            val = row.get("Curator/Social Links", "")
+            if pd.isna(val):
+                links_str = ""
+            else:
+                links_str = str(val).strip()
+
+            channel, chosen_link = parse_channel_and_link(links_str)
+
+            # ✅ 一律将 None/NaN/空白 归一为 ""
+            safe_link = "" if (chosen_link is None or (isinstance(chosen_link, float) and pd.isna(chosen_link)) or not str(chosen_link).strip()) else str(chosen_link).strip()
+
+            # Build email
+            if len(pool) < key_num:
+                print(f"WARNING: Not enough keys left for UID {uid}. Needed {key_num}, have {len(pool)}. Skipping.", file=sys.stderr)
+                status = "SKIPPED_NO_KEYS"
+                assigned = []
+                to_addr = ""
+                final_subject = args.subject
+            else:
+                assigned = pool[:key_num]
+
+                # subject: --subject + ' RE: ' + original
+                orig_subject = str(row.get("Subject", "")).strip()
+                final_subject = args.subject
+                if orig_subject:
+                    final_subject = f"{args.subject} RE: {orig_subject}"
+
+                html = render_email_html(template_html, curator_name, key_num, assigned)
+
+                # Decide recipient
+                to_addr = args.test_email if args.test and args.test_email else (os.environ.get("TEST_EMAIL") if args.test else email_to)
+                if args.test and not to_addr:
+                    print("ERROR: --test specified but no test email provided. Use --test-email or TEST_EMAIL env.", file=sys.stderr)
+                    sys.exit(4)
+
+                # Send (or dry-run)
+                send_email(
+                    smtp_host=args.smtp_host, smtp_port=args.smtp_port,
+                    smtp_user=args.smtp_user, smtp_pass=args.smtp_pass,
+                    from_name=args.from_name, from_email=args.from_email,
+                    replyto=uid,
+                    to_email=to_addr, subject=final_subject, html=html,
+                    dry_run=args.dry_run
+                )
+
+                status = "SENT_TEST" if args.test else ("DRY_RUN" if args.dry_run else "SENT")
+
+                if status == "SENT":
+                    uids_to_mark_answered.append(uid)
+
+                # ===== 进度统计(有分配到 key 才算一次尝试)=====
+                if assigned:
+                    attempt_rows += 1
+                    if status in ("SENT", "SENT_TEST"):
+                        emails_ok += 1
+                        keys_assigned_total += len(assigned)
+                    else:
+                        emails_fail += 1
+
+                # ===== 实时进度反馈(不是 dry-run 就打印)=====
+                if not args.dry_run:
+                    # processed 是你脚本里原本就有的计数器:处理了多少“邮件行”
+                    # total_target 是开始前估算的目标处理量(用于百分比展示)
+                    pct = (processed / total_target * 100) if total_target else 0
+                    print(f"[{processed}/{total_target} | {pct:.1f}%] {status}  UID={uid}  to={to_addr or email_to}  keys={len(assigned)}")
+
+                # Consume keys ONLY when real send (not dry-run, not test) and not --no-consume
+                if not args.no_consume:
+                    pool = pool[key_num:]
+
+            # Prepare BJ timestamps
+            now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
+            send_date = now_bj.strftime("%Y-%m-%d")                # YYYY-MM-DD
+            sent_at = now_bj.strftime("%Y-%m-%d %H:%M:%S")         # detailed
+
+            # Log ONE ROW PER KEY
+            if assigned:
+                for k in assigned:
+                    log_rows.append({
+                        "No.": row_no,
+                        "Send Date": send_date,
+                        "Channel": channel or "Steam",
+                        "Curator/Name": curator_name,
+                        "Purpose": "评测",
+                        "Social Link": safe_link,
+                        "Key": k,
+                        "Mailbox Key": uid,
+                        "To": email_to,
+                        "Requested Key Count": key_num,
+                        "Subject": final_subject,
+                        "Status": status,
+                        "Sent At": sent_at,
+                        "Test Mode": bool(args.test),
+                        "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
+                    })
+                    row_no += 1
+            else:
+                # Even if skipped/no keys, write a single row for traceability (without Key)
+                log_rows.append({
+                    "No.": row_no,
+                    "Send Date": send_date,
+                    "Channel": channel or "Steam",
+                    "Curator/Name": curator_name,
+                    "Purpose": "评测",
+                    "Social Link": chosen_link,
+                    "Key": "",
+                    "Mailbox Key": uid,
+                    "To": email_to,
+                    "Requested Key Count": key_num,
+                    "Subject": final_subject,
+                    "Status": status,
+                    "Sent At": sent_at,
+                    "Test Mode": bool(args.test),
+                    "Actual Recipient": to_addr if (args.test or args.dry_run) else email_to,
+                })
+                row_no += 1
+
+            processed += 1
+
+        except Exception as e:
+            now_bj = datetime.now(ZoneInfo("Asia/Shanghai"))
+            log_rows.append({
+                "No.": row_no,
+                "Send Date": now_bj.strftime("%Y-%m-%d"),
+                "Channel": "Steam",
+                "Curator/Name": str(row.get("Curator/Name", "")),
+                "Purpose": "评测",
+                "Social Link": str(row.get("Curator/Social Links", "")),
+                "Key": "",
+                "Mailbox Key": str(row.get("Mailbox Key", "")),
+                "To": str(row.get("Email", "")),
+                "Requested Key Count": row.get("Requested Key Count"),
+                "Subject": str(row.get("Subject", "")),
+                "Status": f"ERROR:{e}",
+                "Sent At": now_bj.strftime("%Y-%m-%d %H:%M:%S"),
+                "Test Mode": bool(args.test),
+                "Actual Recipient": "",
+            })
+            row_no += 1
+            print(f"ERROR processing row {idx}: {e}", file=sys.stderr)
+            continue
+
+    if args.mark_answered and not args.dry_run and not args.test and uids_to_mark_answered:
+        done = imap_mark_answered_batch(
+            host=args.imap_host, port=args.imap_port,
+            user=args.imap_user, pwd=args.imap_pass,
+            mailbox=args.imap_mailbox, uids=uids_to_mark_answered, batch_size=500
+        )
+        print(f"[INFO] Marked {done} message(s) as \\Answered.")
+
+    # Build DataFrame with desired column order
+    columns = [
+        "No.", "Send Date", "Channel", "Curator/Name", "Purpose", "Social Link",
+        "Key", "Mailbox Key", "To", "Requested Key Count", "Subject",
+        "Status", "Sent At", "Test Mode", "Actual Recipient"
+    ]
+    log_df = pd.DataFrame(log_rows, columns=columns)
+    log_path = os.path.abspath(args.out)
+    with pd.ExcelWriter(log_path, engine="openpyxl") as writer:
+        log_df.to_excel(writer, sheet_name="SendLog", index=False)
+
+    # ===== 汇总报告 =====
+    mode = "REAL"
+    if args.dry_run:
+        mode = "DRY-RUN"
+    elif args.test:
+        mode = "TEST"
+    print("\n=== RUN SUMMARY ===")
+    print(f"Mode:                 {mode}")
+    print(f"Email rows processed: {processed}")
+    print(f"Attempted sends:      {attempt_rows}  (rows that had keys assigned)")
+    print(f"Successful sends:     {emails_ok}")
+    print(f"Failed/Skipped:       {emails_fail}")
+    print(f"Keys assigned total:  {keys_assigned_total}")
+    print(f"Log file:             {log_path}")
+
+
+    # Save remaining keys if consuming (not dry-run/test)
+    if not args.no_consume and not args.dry_run and not args.test:
+        save_remaining_keys(args.keys, pool)
+
+    print(f"Done. Processed {processed} email row(s). Logged {len(log_df)} key row(s). File: {log_path}")
+    if not args.no_consume:
+        print(f"Current in-memory remaining keys (not saved if test/dry-run): {len(pool)}")
+
+if __name__ == "__main__":
+    main()

二进制
curator_requests.xlsx


二进制
curator_requests.xlsx.bak.xlsx


+ 7 - 0
curator_state.json

@@ -0,0 +1,7 @@
+{
+  "mailboxes": {
+    "imap.feishu.cn|support@suchone.com.cn|INBOX": {
+      "last_uid": "569"
+    }
+  }
+}

+ 30 - 0
email_template.html

@@ -0,0 +1,30 @@
+<html>
+  <body>
+    <p>Hi <b>{curator_name}</b>,</p>
+
+    <p>Thank you for your interest in <b>Such A Guy</b>!  
+    We’re happy to provide you with <b>{key_num} Steam key(s)</b>:</p>
+
+    <p><b>{keys_block}</b></p>
+
+    <p><b>Included materials:</b><br/>
+    Press kit (capsules, screenshots, short trailer):<br/>
+    <a href="https://drive.google.com/drive/folders/15h5IQWy0AD1TFBz2Jgd2lNp9CkA3szY8?usp=sharing">
+      Google Drive Press Kit
+    </a><br/>
+    Review notes (features, estimated length, content warnings):<br/>
+    <a href="https://docs.google.com/document/d/1aTPTiDxCbsd3Ie4tNK47LUcYYzIZw5Yf4nveJG4KT7s/edit?usp=sharing">
+      Google Docs Review Notes
+    </a></p>
+
+    <p>We’d really appreciate your honest impressions after you’ve tried the game —  
+    both on your <b>Steam Curator page</b> and as a <b>store review</b> on Steam.  
+    If you enjoyed the experience, even a short recommendation would help more players discover it. 💫</p>
+
+    <p>Thank you again for your time and support!</p>
+
+    <p>Best,<br/>
+    Krystic<b>@ SUCH ONE STUDIO</b></p>
+  </body>
+</html>
+

+ 681 - 0
imap_curator_export.py

@@ -0,0 +1,681 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+imap_curator_export.py
+----------------------
+Fetch emails via IMAP, find Steam curator key requests since a chosen date,
+and export results to an Excel file.
+
+Features:
+- Connectivity test (--test) that does NOT read/write state.
+- Stateful resume via UID bookmarking (JSON). Next run scans only newer UIDs.
+- Extracts curator/social links and requested key counts (defaults to 2 if not found).
+- Filters OUT reply/forward messages (replies to you), detected via headers and subject prefixes.
+- Outputs Excel with requested column order:
+    1) No. (auto-increment)
+    2) Mailbox Key (UID of the message)
+    3) Requested Key Count
+    4) Date (Beijing time, "YYYY-MM-DD HH:MM")
+    5) Curator/Name
+    6) Email
+    7) Subject
+    8) Curator/Social Links
+    9) Body (preview)
+- Prints a summary after export:
+    * Total keys requested
+    * Keys with social links present
+    * Keys without social links
+
+USAGE
+-----
+1) Install deps (ideally in a virtualenv):
+   pip install -r requirements.txt
+
+2) Set environment variables (examples for macOS/Linux bash/zsh):
+   export IMAP_HOST="imap.gmail.com"         # e.g. imap.gmail.com, outlook.office365.com
+   export IMAP_PORT="993"                    # usually 993
+   export EMAIL_USER="your_email@example.com"
+   export EMAIL_PASS="your_app_password"     # For Gmail/Outlook, use an App Password or OAuth token string
+   export MAILBOX="INBOX"                    # optional, default INBOX
+   # Start date used ONLY for the first run (no state yet). IMAP format DD-Mon-YYYY.
+   export START_DATE="18-Oct-2025"
+   # Optional: where to store/read bookmark state per mailbox
+   export STATE_FILE="curator_state.json"
+
+3) Run a connectivity test (no state read/write):
+   python imap_curator_export.py --test
+
+4) Run extraction:
+   python imap_curator_export.py
+
+5) Reset bookmark (force full scan from START_DATE next time):
+   python imap_curator_export.py --reset-state
+
+Output:
+   ./curator_requests.xlsx
+"""
+
+import os
+import re
+import sys
+import json
+import imaplib
+import email
+import argparse
+from email.header import decode_header, make_header
+from email.policy import default as default_policy
+from email.utils import parsedate_to_datetime
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
+from typing import List, Dict, Any, Optional
+
+# Third-party
+from bs4 import BeautifulSoup  # for HTML -> text
+import pandas as pd
+
+import re
+
+CURATOR_LINK_PATTERNS = [
+    r"store\.steampowered\.com/curator/",
+    r"steamcommunity\.com/groups/",
+    r"steamcommunity\.com/id/",
+    r"x\.com/|twitter\.com/",
+    r"youtube\.com/|youtu\.be/",
+    r"twitch\.tv/",
+    r"discord\.gg/|discord\.com/invite/",
+    r"facebook\.com/",
+    r"instagram\.com/",
+    r"tiktok\.com/",
+    r"bilibili\.com/",
+    r"weibo\.com/",
+]
+
+# Heuristics for detecting a request for Steam keys
+KEY_REQUEST_PATTERNS = [
+    r"\b(\d{1,3})\s*(?:keys?|key codes?|steam\s*keys?)\b",
+    r"(?:需要|申请|索取|来点|要)\s*(\d{1,3})\s*(?:个)?\s*(?:key|激活码|序列号|钥匙)",
+    r"\bup to\s*(\d{1,3})\s*keys?\b",
+    r"\brequest(?:ing)?\s*(\d{1,3})\s*keys?\b",
+    r"\b(\d{1,3})\s*x\s*keys?\b",
+    r"\b(\d{1,3})-(\d{1,3})\s*keys?\b",
+]
+
+CURATOR_KEYWORDS = [
+    "curator", "steam curator", "reviewer",
+    "鉴赏家", "评测", "媒体", "KOL", "influencer", "press",
+    "key", "keys", "激活码", "序列号", "steam key",
+]
+
+# Subject prefixes indicating replies/forwards (EN/ZH)
+REPLY_FWD_PREFIX = re.compile(
+    r"^\s*(re(\[\d+\])?|回复|答复|答覆|转发|fw|fwd)\s*[::]\s*",
+    re.IGNORECASE
+)
+
+def extract_name_from_body(body: str) -> str:
+    """
+    从正文中根据常见短语提取名字:
+    例如 "My name is Alex", "I am John", "This is Lily"。
+    """
+    if not body:
+        return ""
+
+    text = body.strip().replace("\r", "").replace("\n", " ")
+
+    # 常见英语自我介绍
+    patterns = [
+        r"\bmy name is ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
+        r"\bi am ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
+        r"\bthis is ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
+    ]
+
+    for pat in patterns:
+        m = re.search(pat, text, flags=re.IGNORECASE)
+        if m:
+            # 返回匹配到的名字(首字母大写格式化)
+            name = m.group(1).strip()
+            return name.title()
+
+    # 末尾签名行的简易提取(如 “Thanks, Alex”)
+    m = re.search(r"(?:regards|thanks|cheers)[,:\s]+([A-Z][a-z]+(?: [A-Z][a-z]+)?)", text, flags=re.IGNORECASE)
+    if m:
+        return m.group(1).strip().title()
+
+    return ""
+
+def norm_email(addr: str) -> str:
+    return (addr or "").strip().lower()
+
+def chunked(seq, n):
+    for i in range(0, len(seq), n):
+        yield seq[i:i+n]
+
+def mark_seen_batch(M, mailbox: str, uids: list[str], batch_size: int = 500) -> int:
+    """
+    以批为单位,把同一 mailbox 中的多封邮件标记为已读。
+    返回成功提交 STORE 的 UID 总数(不逐一校验)。
+    """
+    if not uids:
+        return 0
+    # 切换到可写
+    typ, _ = M.select(mailbox, readonly=False)
+    if typ != "OK":
+        print(f"[WARN] cannot select {mailbox} in write mode; skip mark-read")
+        return 0
+
+    total = 0
+    for batch in chunked(uids, batch_size):
+        # UID 序列(逗号拼接)
+        seqset = ",".join(str(u.decode() if isinstance(u, (bytes, bytearray)) else u) for u in batch)
+
+        # 先静默,减少服务器返回
+        typ, _ = M.uid("STORE", seqset, "+FLAGS.SILENT", r"(\Seen)")
+        if typ != "OK":
+            # 兼容某些服务器
+            typ, _ = M.uid("STORE", seqset, "+FLAGS", r"(\Seen)")
+        if typ == "OK":
+            total += len(batch)
+        else:
+
+            print(f"[WARN] batch STORE failed for {len(batch)} UIDs (len={len(seqset)})")
+
+    return total
+
+
+def env_get(name: str, default: Optional[str] = None, required: bool = False) -> str:
+    v = os.environ.get(name, default)
+    if required and not v:
+        print(f"ERROR: Missing environment variable {name}", file=sys.stderr)
+        sys.exit(1)
+    return v
+
+
+def imap_login() -> imaplib.IMAP4_SSL:
+    host = env_get("IMAP_HOST", required=True)
+    port = int(env_get("IMAP_PORT", "993"))
+    user = env_get("EMAIL_USER", required=True)
+    pwd  = env_get("EMAIL_PASS", required=True)
+
+    M = imaplib.IMAP4_SSL(host, port)
+    M.login(user, pwd)
+    return M
+
+
+def select_mailbox(M: imaplib.IMAP4_SSL) -> str:
+    mailbox = env_get("MAILBOX", "INBOX")
+    typ, data = M.select(mailbox, readonly=False)
+    if typ != "OK":
+        raise RuntimeError(f"Cannot select mailbox {mailbox}: {typ} {data}")
+    return mailbox
+
+
+def imap_date_string() -> str:
+    start_date = env_get("START_DATE", "18-Oct-2025")
+    try:
+        datetime.strptime(start_date, "%d-%b-%Y")
+    except Exception:
+        print("WARNING: START_DATE is not in 'DD-Mon-YYYY' (e.g., 18-Oct-2025). Using 18-Oct-2025 by default.", file=sys.stderr)
+        start_date = "18-Oct-2025"
+    return start_date
+
+
+def load_state() -> Dict[str, Any]:
+    path = env_get("STATE_FILE", "curator_state.json")
+    if not os.path.exists(path):
+        return {"_path": path, "mailboxes": {}}
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        data["_path"] = path
+        if "mailboxes" not in data or not isinstance(data["mailboxes"], dict):
+            data["mailboxes"] = {}
+        return data
+    except Exception:
+        return {"_path": path, "mailboxes": {}}
+
+
+def save_state(state: Dict[str, Any]) -> None:
+    path = state.get("_path", env_get("STATE_FILE", "curator_state.json"))
+    tmp = path + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump({k: v for k, v in state.items() if k != "_path"}, f, ensure_ascii=False, indent=2)
+    os.replace(tmp, path)
+
+
+def get_mailbox_key(host: str, user: str, mailbox: str) -> str:
+    return f"{host}|{user}|{mailbox}"
+
+
+def uid_search(M: imaplib.IMAP4_SSL, criterion: str) -> List[bytes]:
+    typ, data = M.uid("search", None, criterion)
+    if typ != "OK":
+        raise RuntimeError(f"UID SEARCH failed: {typ} {data}")
+    return data[0].split()
+
+
+def search_initial(M: imaplib.IMAP4_SSL, since_date: str) -> List[bytes]:
+    typ, data = M.search(None, f'(SINCE "{since_date}")')
+    if typ != "OK":
+        raise RuntimeError(f"SEARCH failed: {typ} {data}")
+    ids = data[0].split()
+    uids: List[bytes] = []
+    if not ids:
+        return uids
+    for i in range(0, len(ids), 500):
+        batch = ids[i:i+500]
+        typ2, data2 = M.fetch(b",".join(batch), "(UID)")
+        if typ2 != "OK":
+            continue
+        for part in data2:
+            if not isinstance(part, tuple):
+                continue
+            header = part[0].decode("utf-8", "ignore")
+            m = re.search(r"UID\s+(\d+)", header)
+            if m:
+                uids.append(m.group(1).encode())
+    return uids
+
+
+def decode_str(s: Optional[str]) -> str:
+    if not s:
+        return ""
+    try:
+        return str(make_header(decode_header(s)))
+    except Exception:
+        return s
+
+
+def get_address(msg: email.message.Message) -> (str, str):
+    from_raw = msg.get("From", "")
+    name = email.utils.parseaddr(from_raw)[0]
+    addr = email.utils.parseaddr(from_raw)[1]
+    return decode_str(name).strip(), addr.strip()
+
+
+def get_subject(msg: email.message.Message) -> str:
+    return decode_str(msg.get("Subject", "")).strip()
+
+
+def get_payload_text(msg: email.message.Message) -> (str, str):
+    plain_parts = []
+    html_parts = []
+
+    if msg.is_multipart():
+        for part in msg.walk():
+            ctype = part.get_content_type()
+            disp = str(part.get_content_disposition() or "").lower()
+            if disp == "attachment":
+                continue
+            try:
+                payload = part.get_payload(decode=True)
+            except Exception:
+                payload = None
+
+            if payload is None:
+                continue
+
+            charset = part.get_content_charset() or "utf-8"
+            try:
+                text = payload.decode(charset, errors="replace")
+            except Exception:
+                text = payload.decode("utf-8", errors="replace")
+
+            if ctype == "text/plain":
+                plain_parts.append(text)
+            elif ctype == "text/html":
+                html_parts.append(text)
+    else:
+        payload = msg.get_payload(decode=True) or b""
+        charset = msg.get_content_charset() or "utf-8"
+        try:
+            text = payload.decode(charset, errors="replace")
+        except Exception:
+            text = payload.decode("utf-8", errors="replace")
+        if msg.get_content_type() == "text/html":
+            html_parts.append(text)
+        else:
+            plain_parts.append(text)
+
+    return ("\n".join(plain_parts).strip(), "\n".join(html_parts).strip())
+
+
+def html_to_text(html: str) -> str:
+    if not html:
+        return ""
+    soup = BeautifulSoup(html, "html.parser")
+    for tag in soup(["script", "style"]):
+        tag.decompose()
+    return soup.get_text(separator="\n").strip()
+
+
+def extract_links(text: str) -> List[str]:
+    if not text:
+        return []
+    urls = re.findall(r"https?://[^\s<>()\"\']+", text, flags=re.IGNORECASE)
+    seen = set()
+    out = []
+    for u in urls:
+        if u not in seen:
+            seen.add(u)
+            out.append(u)
+    return out
+
+
+def filter_curator_links(urls: List[str]) -> List[str]:
+    if not urls:
+        return []
+    combined = "|".join(CURATOR_LINK_PATTERNS)
+    pat = re.compile(combined, re.IGNORECASE)
+    return [u for u in urls if pat.search(u)]
+
+
+def detect_key_count(text: str) -> Optional[int]:
+    if not text:
+        return None
+    best = None
+    for pat in KEY_REQUEST_PATTERNS:
+        m = re.search(pat, text, flags=re.IGNORECASE)
+        if m:
+            if m.lastindex and m.lastindex >= 2 and m.group(1) and m.group(2):
+                try:
+                    a = int(m.group(1))
+                    b = int(m.group(2))
+                    best = max(a, b)
+                    break
+                except Exception:
+                    continue
+            else:
+                nums = [int(g) for g in m.groups() if g and g.isdigit()]
+                if nums:
+                    best = max(nums)
+                    break
+    return best
+
+
+def looks_like_curator_request(subject: str, body_text: str) -> bool:
+    blob = f"{subject}\n{body_text}".lower()
+    return any(k.lower() in blob for k in CURATOR_KEYWORDS)
+
+
+def fetch_by_uid(M: imaplib.IMAP4_SSL, uid: bytes) -> email.message.Message:
+    typ, data = M.uid("fetch", uid, "(RFC822)")
+    if typ != "OK" or not data or not isinstance(data[0], tuple):
+        raise RuntimeError(f"UID FETCH failed for {uid!r}: {typ} {data}")
+    raw = data[0][1]
+    msg = email.message_from_bytes(raw, policy=default_policy)
+    return msg
+
+
+def parse_msg_date_bj(msg: email.message.Message) -> str:
+    raw = msg.get("Date") or ""
+    try:
+        dt = parsedate_to_datetime(raw)
+        if dt is None:
+            raise ValueError("parsedate_to_datetime returned None")
+        if dt.tzinfo is None:
+            dt = dt.replace(tzinfo=timezone.utc)
+        dt_cst = dt.astimezone(ZoneInfo("Asia/Shanghai"))
+        return dt_cst.strftime("%Y-%m-%d %H:%M")
+    except Exception:
+        return ""
+
+
+def is_reply_or_forward(msg: email.message.Message, subject: str) -> bool:
+    # Header-based
+    if msg.get("In-Reply-To") or msg.get("References"):
+        return True
+    # Subject-based
+    if REPLY_FWD_PREFIX.search(subject):
+        return True
+    return False
+
+
+def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
+    msg = fetch_by_uid(M, uid)
+    name, addr = get_address(msg)
+    subject = get_subject(msg)
+    date_local = parse_msg_date_bj(msg)
+    reply_flag = is_reply_or_forward(msg, subject)
+
+    plain, html = get_payload_text(msg)
+    merged_text = plain.strip()
+    if html and (not merged_text or len(merged_text) < 20):
+        merged_text = html_to_text(html)
+
+    links_all = extract_links(plain + "\n" + html)
+    curator_links = filter_curator_links(links_all)
+
+    key_count = detect_key_count(merged_text)
+    if key_count is None:
+        key_count = 2  # default when not specified
+
+    return {
+        "uid": uid.decode() if isinstance(uid, (bytes, bytearray)) else str(uid),
+        "from_name": name or "",
+        "from_email": addr or "",
+        "subject": subject,
+        "date_local": date_local,
+        "body_preview": (merged_text[:3000] + ("..." if len(merged_text) > 3000 else "")),
+        "curator_links": curator_links,
+        "key_count": int(key_count),
+        "is_reply": reply_flag,
+    }
+
+
+def connectivity_test() -> int:
+    try:
+        M = imap_login()
+        try:
+            mailbox = select_mailbox(M)
+            uids = uid_search(M, "ALL")
+            count = len(uids)
+            if count == 0:
+                print(f"[TEST] Connected to {mailbox}, but it has no messages.")
+                return 0
+            latest_uid = uids[-1]
+            msg = fetch_by_uid(M, latest_uid)
+            subject = get_subject(msg)
+            from_name, from_addr = get_address(msg)
+            date_local = parse_msg_date_bj(msg)
+            print("[TEST] IMAP OK.")
+            print(f"[TEST] Mailbox: {mailbox}")
+            print(f"[TEST] Total messages: {count}")
+            print(f"[TEST] Latest UID: {latest_uid.decode()}")
+            print(f"[TEST] Latest From: {from_name} <{from_addr}>")
+            print(f"[TEST] Latest Subject: {subject}")
+            print(f"[TEST] Latest Date (BJ): {date_local}")
+            return 0
+        finally:
+            try:
+                M.logout()
+            except Exception:
+                pass
+    except Exception as e:
+        print(f"[TEST] IMAP failed: {e}", file=sys.stderr)
+        return 2
+
+
+def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
+    since_date = imap_date_string()
+    host = env_get("IMAP_HOST", required=True)
+    user = env_get("EMAIL_USER", required=True)
+    min_uid = int(env_get("MIN_UID", "125"))
+
+    M = imap_login()
+    state = load_state()
+    try:
+        mailbox = select_mailbox(M)
+        mailbox_key = get_mailbox_key(host, user, mailbox)
+
+        if reset_state and mailbox_key in state["mailboxes"]:
+            del state["mailboxes"][mailbox_key]
+            save_state(state)
+            print(f"[STATE] Reset bookmark for {mailbox_key}")
+
+        last_uid = None
+        if mailbox_key in state["mailboxes"]:
+            last_uid = state["mailboxes"][mailbox_key].get("last_uid")
+
+        if last_uid:
+            criterion = f"(UID {int(last_uid)+1}:*)"
+            uids = uid_search(M, criterion)
+            print(f"[SCAN] Using bookmark last_uid={last_uid}; new UIDs found: {len(uids)}")
+        else:
+            uids = search_initial(M, since_date)
+            print(f"[SCAN] Initial run since {since_date}; candidate UIDs: {len(uids)}")
+
+        rows: List[Dict[str, Any]] = []
+        all_uids_for_mark: list[str] = []
+        max_seen_uid = int(last_uid) if last_uid else 0
+        row_no = 1
+
+        best_by_email: dict[str, dict] = {}
+        dup_skipped = 0
+
+        for i, uid in enumerate(uids, 1):
+            try:
+                uid_int = int(uid)
+                if uid_int < min_uid:
+                    continue
+
+                rec = fetch_and_parse(M, uid)
+                if uid_int > max_seen_uid:
+                    max_seen_uid = uid_int
+
+                # Exclude replies/forwards
+                if rec["is_reply"]:
+                    continue
+
+                if looks_like_curator_request(rec["subject"], rec["body_preview"]):
+                    has_links = bool(rec["curator_links"])
+                    curator_email = norm_email(rec["from_email"])
+                    dedup_key = curator_email if curator_email else f"uid:{rec['uid']}"
+
+
+                    # 首选:邮件头中的名字
+                    if rec["from_name"]:
+                        curator_name = rec["from_name"].strip()
+                    else:
+                        # 尝试从正文里提取名字
+                        extracted = extract_name_from_body(rec["body_preview"])
+                        if extracted:
+                            curator_name = extracted
+                        #else:
+                        #    # 兜底用邮箱前缀
+                        #    curator_name = "Curator"
+
+                    candidate = {
+                        "uid_int": int(uid),
+                        "record": {
+                            "Mailbox Key": rec["uid"],                 # UID
+                            "Requested Key Count": rec["key_count"],
+                            "Date": rec["date_local"],
+                            "Curator/Name": curator_name,
+                            "Email": rec["from_email"],
+                            "Subject": rec["subject"],
+                            "Curator/Social Links": ", ".join(rec["curator_links"]) if has_links else "",
+                            "Body (preview)": rec["body_preview"],
+                            "_has_links": has_links,
+                        }
+                    }
+                    
+                    all_uids_for_mark.append(int(uid))
+                    prev = best_by_email.get(dedup_key)
+                    if prev is None or candidate["uid_int"] > prev["uid_int"]:
+                        best_by_email[dedup_key] = candidate
+                    else:
+                        dup_skipped += 1
+                    
+                if i % 10 == 0:
+                    pct = (i / len(uids)) * 100
+                    print(f"  Processed {pct:.1f}% ({i}/{len(uids)})")
+            except Exception as e:
+                print(f"[WARN] Failed to parse UID {uid!r}: {e}", file=sys.stderr)
+                continue
+
+        if mark_read:
+            done = mark_seen_batch(M, mailbox, all_uids_for_mark, batch_size=500)
+            print(f"[INFO] Marked {done} message(s) as read in batches.")
+
+        rows = []
+        row_no = 1
+        selected_uids_for_mark = []
+
+        for _, v in best_by_email.items():
+            rec = v["record"]
+            rows.append({
+                "No.": row_no,
+                "Mailbox Key": rec["Mailbox Key"],
+                "Requested Key Count": rec["Requested Key Count"],
+                "Date": rec["Date"],
+                "Curator/Name": rec["Curator/Name"],
+                "Email": rec["Email"],
+                "Subject": rec["Subject"],
+                "Curator/Social Links": rec["Curator/Social Links"],
+                "Body (preview)": rec["Body (preview)"],
+                "_has_links": rec["_has_links"],
+            })
+            selected_uids_for_mark.append(rec["Mailbox Key"])
+            row_no += 1
+
+        # Save bookmark even if no rows matched, so daily runs skip already-seen messages
+        state["mailboxes"][mailbox_key] = {"last_uid": str(max_seen_uid)}
+        save_state(state)
+        print(f"[STATE] Updated last_uid={max_seen_uid} for {mailbox_key}")
+
+        columns = [
+            "No.", "Mailbox Key", "Requested Key Count", "Date", "Curator/Name",
+            "Email", "Subject", "Curator/Social Links", "Body (preview)"
+        ]
+
+        if not rows:
+            print("No curator key requests matched the filters.")
+            df = pd.DataFrame(columns=columns)
+            total_keys = with_links = without_links = 0
+        else:
+            df = pd.DataFrame(rows)
+            df["Requested Key Count"] = pd.to_numeric(df["Requested Key Count"], errors="coerce").fillna(0).astype(int)
+            total_keys = int(df["Requested Key Count"].sum())
+            with_links = int(df.loc[df["_has_links"], "Requested Key Count"].sum()) if "_has_links" in df.columns else 0
+            without_links = total_keys - with_links
+            # drop helper
+            if "_has_links" in df.columns:
+                df = df.drop(columns=["_has_links"])
+            df = df[columns]
+
+        out_path = os.path.abspath("curator_requests.xlsx")
+        with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
+            df.to_excel(writer, sheet_name="Requests", index=False)
+
+        # Summary printout
+        print("\n=== SUMMARY ===")
+        print(f"Total requested keys: {total_keys}")
+        print(f"With social links:    {with_links}")
+        print(f"Without social links: {without_links}")
+
+        print(f"\nExported {len(df)} row(s) to {out_path}")
+        return 0
+    finally:
+        try:
+            M.logout()
+        except Exception:
+            pass
+
+
+def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
+    p = argparse.ArgumentParser(description="Export Steam curator key requests to Excel via IMAP.")
+    p.add_argument("--test", action="store_true", help="Run a quick IMAP connectivity test and exit (does not read/write state).")
+    p.add_argument("--reset-state", action="store_true", help="Reset stored UID bookmark before running.")
+    p.add_argument("--mark-read", action="store_true", help="After exporting, mark those emails as read on the IMAP server.")
+    return p.parse_args(argv)
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+    args = parse_args(argv)
+    if args.test:
+        return connectivity_test()   # no state read/write
+    return run_export(reset_state=args.reset_state, mark_read=args.mark_read)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 196 - 0
imap_flags_inspector.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+imap_flags_inspector.py
+Inspect FLAGS for messages on an IMAP server (e.g., Gmail),
+to compare what the client recognizes as read/unread/flagged etc.
+
+Usage examples:
+  # 1) List FLAGS for last 200 messages in INBOX
+  python imap_flags_inspector.py --host imap.gmail.com --user you@gmail.com --mailbox INBOX --search ALL --limit 200
+
+  # 2) Only UNSEEN messages, show mailbox meta and server capabilities
+  python imap_flags_inspector.py --host imap.gmail.com --user you@gmail.com --mailbox INBOX --search UNSEEN --show-mailbox-meta --show-capabilities
+
+  # 3) Specify UIDs explicitly (comma-separated)
+  python imap_flags_inspector.py --host imap.gmail.com --user you@gmail.com --mailbox INBOX --uids 12345,12346
+
+Env vars (fallbacks for args):
+  IMAP_HOST, IMAP_PORT, IMAP_SSL, EMAIL_USER, EMAIL_PASS, MAILBOX
+"""
+
+import os
+import sys
+import imaplib
+import argparse
+from email import message_from_bytes
+from email.header import decode_header, make_header
+
+def env_default(name, default=None, cast=str):
+    v = os.environ.get(name, None)
+    if v is None:
+        return default
+    try:
+        return cast(v)
+    except Exception:
+        return default
+
+def decode_field(raw):
+    if not raw:
+        return ""
+    try:
+        # raw is str already?
+        if isinstance(raw, str):
+            return str(make_header(decode_header(raw)))
+        # bytes
+        return str(make_header(decode_header(raw.decode("utf-8", errors="replace"))))
+    except Exception:
+        try:
+            return raw.decode("utf-8", errors="replace")
+        except Exception:
+            return str(raw)
+
+def fetch_headers(M, uid):
+    # Only fetch small subset of headers for readability
+    typ, data = M.uid("FETCH", uid, '(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])')
+    subject, from_, date_ = "", "", ""
+    if typ == "OK" and data and isinstance(data[0], tuple):
+        msg = message_from_bytes(data[0][1])
+        subject = decode_field(msg.get("Subject", ""))
+        from_   = decode_field(msg.get("From", ""))
+        date_   = decode_field(msg.get("Date", ""))
+        replyto = decode_field(msg.get("Reply-To", ""))
+        references = decode_field(msg.get("References", ""))
+    return subject, from_, date_, replyto, references
+
+def fetch_flags(M, uid):
+    typ, data = M.uid("FETCH", uid, "(FLAGS)")
+    flags = ""
+    if typ == "OK" and data:
+        # data could be [(b'123 (FLAGS (\Seen \Flagged))', b'')]
+        blob = b" ".join(part if isinstance(part, (bytes, bytearray)) else b""
+                         for part in data)
+        flags = blob.decode("utf-8", errors="replace")
+        # extract inside FLAGS (...) if present
+        # not strictly required; printing the blob helps debugging servers
+    return flags
+
+def main():
+    ap = argparse.ArgumentParser(description="Inspect IMAP FLAGS for messages.")
+    ap.add_argument("--host", default=env_default("IMAP_HOST", "imap.gmail.com"))
+    ap.add_argument("--port", type=int, default=env_default("IMAP_PORT", 993, int))
+    ap.add_argument("--ssl", type=str, default=env_default("IMAP_SSL", "1"), help="1/true/on = IMAP over SSL; 0/false = plain + STARTTLS if --starttls")
+    ap.add_argument("--starttls", action="store_true", help="Use STARTTLS if not using SSL")
+    ap.add_argument("--user", default=env_default("EMAIL_USER", ""))
+    ap.add_argument("--password", default=env_default("EMAIL_PASS", ""))
+    ap.add_argument("--mailbox", default=env_default("MAILBOX", "INBOX"))
+    ap.add_argument("--readonly", action="store_true", help="Select mailbox read-only (safe for inspection).")
+    ap.add_argument("--search", action="append", help="IMAP SEARCH key(s), e.g. ALL / UNSEEN / SINCE 01-Oct-2025. Can be repeated.")
+    ap.add_argument("--uids", help="Comma-separated UID list to fetch (skips SEARCH).")
+    ap.add_argument("--limit", type=int, default=200, help="Max messages to print (after search).")
+    ap.add_argument("--show-capabilities", action="store_true")
+    ap.add_argument("--show-mailbox-meta", action="store_true", help="Show PERMANENTFLAGS / UIDNEXT / UIDVALIDITY / HIGHESTMODSEQ")
+    args = ap.parse_args()
+
+    use_ssl = str(args.ssl).strip().lower() in ("1", "true", "yes", "on")
+
+    # Connect
+    if use_ssl:
+        M = imaplib.IMAP4_SSL(args.host, args.port)
+    else:
+        M = imaplib.IMAP4(args.host, args.port)
+        if args.starttls:
+            M.starttls()
+
+    # Capabilities
+    if args.show_capabilities:
+        try:
+            caps = M.capabilities
+            print("[CAPABILITIES]", caps)
+        except Exception as e:
+            print("[WARN] Get capabilities failed:", e, file=sys.stderr)
+
+    # Login
+    if not args.user or not args.password:
+        print("ERROR: --user/--password (or EMAIL_USER/EMAIL_PASS) is required.", file=sys.stderr)
+        sys.exit(2)
+    M.login(args.user, args.password)
+
+    # Select mailbox
+    typ, data = M.select(args.mailbox, readonly=args.readonly)
+    if typ != "OK":
+        print(f"ERROR: Cannot select mailbox {args.mailbox}: {typ} {data}", file=sys.stderr)
+        sys.exit(3)
+
+    # Show mailbox meta
+    if args.show_mailbox_meta:
+        try:
+            # UIDNEXT
+            typ1, d1 = M.status(args.mailbox, "(UIDNEXT UIDVALIDITY UNSEEN MESSAGES)")
+            print("[MAILBOX STATUS]", d1[0].decode() if d1 and d1[0] else d1)
+        except Exception as e:
+            print("[WARN] STATUS failed:", e, file=sys.stderr)
+        try:
+            # PERMANENTFLAGS / HIGHESTMODSEQ
+            typ2, d2 = M.response("PERMANENTFLAGS")
+            print("[PERMANENTFLAGS]", d2)
+        except Exception as e:
+            print("[WARN] PERMANENTFLAGS failed:", e, file=sys.stderr)
+        try:
+            typ3, d3 = M.response("HIGHESTMODSEQ")
+            print("[HIGHESTMODSEQ]", d3)
+        except Exception as e:
+            print("[WARN] HIGHESTMODSEQ failed:", e, file=sys.stderr)
+
+    # Build UID list
+    uids = []
+    if args.uids:
+        uids = [u.strip() for u in args.uids.split(",") if u.strip()]
+    else:
+        # SEARCH
+        search_criteria = args.search or ["ALL"]
+        # imaplib expects a sequence of *strings* as separate args; join basic tokens by space if user passed multiple
+        # For simplicity we join each provided item into a single string; you can pass things like:
+        #   --search UNSEEN
+        #   --search 'SINCE 01-Oct-2025'
+        for crit in search_criteria:
+            typ, data = M.uid("SEARCH", None, crit)
+            if typ == "OK" and data and data[0]:
+                ids = data[0].decode().split()
+                uids.extend(ids)
+        # de-duplicate and keep order
+        seen = set()
+        uids = [u for u in uids if (u not in seen and not seen.add(u))]
+
+    if not uids:
+        print("[INFO] No messages matched.")
+        M.close(); M.logout()
+        return
+
+    # Limit from the end (usually newer messages have larger UIDs)
+    if args.limit and len(uids) > args.limit:
+        uids = uids[-args.limit:]
+
+    print(f"[INFO] Inspecting {len(uids)} message(s) in {args.mailbox}")
+    print("-" * 80)
+    for uid in uids:
+        flags = fetch_flags(M, uid)
+        subj, from_, date_, rep, ref = fetch_headers(M, uid)
+        print(f"UID: {uid}")
+        print(f"FLAGS: {flags}")
+        print(f"From:  {from_}")
+        print(f"Date:  {date_}")
+        print(f"Subj:  {subj}")
+        print(f"Reply-To: {rep}")
+        print(f"References: {ref}")
+        print("-" * 80)
+
+    try:
+        M.close()
+    except Exception:
+        pass
+    M.logout()
+
+if __name__ == "__main__":
+    main()
+

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+beautifulsoup4
+pandas
+openpyxl

二进制
send_log.xlsx


+ 16 - 0
steam_key.txt

@@ -0,0 +1,16 @@
+B39EY-A2L0H-7MA9M
+DD6QQ-DJ2I6-23QHX
+VGGYH-AWL30-W6I36
+CADFP-79IYH-6RR94
+5BGHW-7A578-LNWJF
+ZWY7E-R46HT-03AND
+YDE2L-5A5WT-G0R68
+F8AKR-9H4VM-LE6KP
+V60BM-QA49Z-DHQV8
+JWBAM-RRQ4W-T3M9K
+J4ZIR-45NL9-LMNAB
+L72VD-VNP3J-AER78
+AIQII-RT4IE-XTY0W
+KN5HE-38CF2-Z5B7H
+R5ZGL-GTEBR-EPPW9
+QBEGD-T297Y-JIMK8