imap_curator_export.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. imap_curator_export.py
  5. ----------------------
  6. Fetch emails via IMAP, find Steam curator key requests since a chosen date,
  7. and export results to an Excel file.
  8. Features:
  9. - Connectivity test (--test) that does NOT read/write state.
  10. - Stateful resume via UID bookmarking (JSON). Next run scans only newer UIDs.
  11. - Extracts curator/social links and requested key counts (defaults to 2 if not found).
  12. - Filters OUT reply/forward messages (replies to you), detected via headers and subject prefixes.
  13. - Outputs Excel with requested column order:
  14. 1) No. (auto-increment)
  15. 2) Mailbox Key (UID of the message)
  16. 3) Requested Key Count
  17. 4) Date (Beijing time, "YYYY-MM-DD HH:MM")
  18. 5) Curator/Name
  19. 6) Email
  20. 7) Subject
  21. 8) Curator/Social Links
  22. 9) Body (preview)
  23. - Prints a summary after export:
  24. * Total keys requested
  25. * Keys with social links present
  26. * Keys without social links
  27. USAGE
  28. -----
  29. 1) Install deps (ideally in a virtualenv):
  30. pip install -r requirements.txt
  31. 2) Set environment variables (examples for macOS/Linux bash/zsh):
  32. export IMAP_HOST="imap.gmail.com" # e.g. imap.gmail.com, outlook.office365.com
  33. export IMAP_PORT="993" # usually 993
  34. export EMAIL_USER="your_email@example.com"
  35. export EMAIL_PASS="your_app_password" # For Gmail/Outlook, use an App Password or OAuth token string
  36. export MAILBOX="INBOX" # optional, default INBOX
  37. # Start date used ONLY for the first run (no state yet). IMAP format DD-Mon-YYYY.
  38. export START_DATE="18-Oct-2025"
  39. # Optional: where to store/read bookmark state per mailbox
  40. export STATE_FILE="curator_state.json"
  41. 3) Run a connectivity test (no state read/write):
  42. python imap_curator_export.py --test
  43. 4) Run extraction:
  44. python imap_curator_export.py
  45. 5) Reset bookmark (force full scan from START_DATE next time):
  46. python imap_curator_export.py --reset-state
  47. Output:
  48. ./curator_requests.xlsx
  49. """
  50. import os
  51. import re
  52. import sys
  53. import json
  54. import imaplib
  55. import email
  56. import argparse
  57. from email.header import decode_header, make_header
  58. from email.policy import default as default_policy
  59. from email.utils import parsedate_to_datetime
  60. from datetime import datetime, timezone
  61. from zoneinfo import ZoneInfo
  62. from typing import List, Dict, Any, Optional
  63. # Third-party
  64. from bs4 import BeautifulSoup # for HTML -> text
  65. import pandas as pd
  66. import re
  67. CURATOR_LINK_PATTERNS = [
  68. r"store\.steampowered\.com/curator/",
  69. r"steamcommunity\.com/groups/",
  70. r"steamcommunity\.com/id/",
  71. r"x\.com/|twitter\.com/",
  72. r"youtube\.com/|youtu\.be/",
  73. r"twitch\.tv/",
  74. r"discord\.gg/|discord\.com/invite/",
  75. r"facebook\.com/",
  76. r"instagram\.com/",
  77. r"tiktok\.com/",
  78. r"bilibili\.com/",
  79. r"weibo\.com/",
  80. ]
  81. # Heuristics for detecting a request for Steam keys
  82. KEY_REQUEST_PATTERNS = [
  83. r"\b(\d{1,3})\s*(?:keys?|key codes?|steam\s*keys?)\b",
  84. r"(?:需要|申请|索取|来点|要)\s*(\d{1,3})\s*(?:个)?\s*(?:key|激活码|序列号|钥匙)",
  85. r"\bup to\s*(\d{1,3})\s*keys?\b",
  86. r"\brequest(?:ing)?\s*(\d{1,3})\s*keys?\b",
  87. r"\b(\d{1,3})\s*x\s*keys?\b",
  88. r"\b(\d{1,3})-(\d{1,3})\s*keys?\b",
  89. ]
  90. CURATOR_KEYWORDS = [
  91. "curator", "steam curator", "reviewer",
  92. "鉴赏家", "评测", "媒体", "KOL", "influencer", "press",
  93. "key", "keys", "激活码", "序列号", "steam key",
  94. ]
  95. # Subject prefixes indicating replies/forwards (EN/ZH)
  96. REPLY_FWD_PREFIX = re.compile(
  97. r"^\s*(re(\[\d+\])?|回复|答复|答覆|转发|fw|fwd)\s*[::]\s*",
  98. re.IGNORECASE
  99. )
  100. def extract_name_from_body(body: str) -> str:
  101. """
  102. 从正文中根据常见短语提取名字:
  103. 例如 "My name is Alex", "I am John", "This is Lily"。
  104. """
  105. if not body:
  106. return ""
  107. text = body.strip().replace("\r", "").replace("\n", " ")
  108. # 常见英语自我介绍
  109. patterns = [
  110. r"\bmy name is ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
  111. r"\bi am ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
  112. r"\bthis is ([A-Z][a-z]+(?: [A-Z][a-z]+)?)",
  113. ]
  114. for pat in patterns:
  115. m = re.search(pat, text, flags=re.IGNORECASE)
  116. if m:
  117. # 返回匹配到的名字(首字母大写格式化)
  118. name = m.group(1).strip()
  119. return name.title()
  120. # 末尾签名行的简易提取(如 “Thanks, Alex”)
  121. m = re.search(r"(?:regards|thanks|cheers)[,:\s]+([A-Z][a-z]+(?: [A-Z][a-z]+)?)", text, flags=re.IGNORECASE)
  122. if m:
  123. return m.group(1).strip().title()
  124. return ""
  125. def norm_email(addr: str) -> str:
  126. return (addr or "").strip().lower()
  127. def chunked(seq, n):
  128. for i in range(0, len(seq), n):
  129. yield seq[i:i+n]
  130. def mark_seen_batch(M, mailbox: str, uids: list[str], batch_size: int = 500) -> int:
  131. """
  132. 以批为单位,把同一 mailbox 中的多封邮件标记为已读。
  133. 返回成功提交 STORE 的 UID 总数(不逐一校验)。
  134. """
  135. if not uids:
  136. return 0
  137. # 切换到可写
  138. typ, _ = M.select(mailbox, readonly=False)
  139. if typ != "OK":
  140. print(f"[WARN] cannot select {mailbox} in write mode; skip mark-read")
  141. return 0
  142. total = 0
  143. for batch in chunked(uids, batch_size):
  144. # UID 序列(逗号拼接)
  145. seqset = ",".join(str(u.decode() if isinstance(u, (bytes, bytearray)) else u) for u in batch)
  146. # 先静默,减少服务器返回
  147. typ, _ = M.uid("STORE", seqset, "+FLAGS.SILENT", r"(\Seen)")
  148. if typ != "OK":
  149. # 兼容某些服务器
  150. typ, _ = M.uid("STORE", seqset, "+FLAGS", r"(\Seen)")
  151. if typ == "OK":
  152. total += len(batch)
  153. else:
  154. print(f"[WARN] batch STORE failed for {len(batch)} UIDs (len={len(seqset)})")
  155. return total
  156. def env_get(name: str, default: Optional[str] = None, required: bool = False) -> str:
  157. v = os.environ.get(name, default)
  158. if required and not v:
  159. print(f"ERROR: Missing environment variable {name}", file=sys.stderr)
  160. sys.exit(1)
  161. return v
  162. def imap_login() -> imaplib.IMAP4_SSL:
  163. host = env_get("IMAP_HOST", required=True)
  164. port = int(env_get("IMAP_PORT", "993"))
  165. user = env_get("EMAIL_USER", required=True)
  166. pwd = env_get("EMAIL_PASS", required=True)
  167. M = imaplib.IMAP4_SSL(host, port)
  168. M.login(user, pwd)
  169. return M
  170. def select_mailbox(M: imaplib.IMAP4_SSL) -> str:
  171. mailbox = env_get("MAILBOX", "INBOX")
  172. typ, data = M.select(mailbox, readonly=True)
  173. if typ != "OK":
  174. raise RuntimeError(f"Cannot select mailbox {mailbox}: {typ} {data}")
  175. return mailbox
  176. def imap_date_string() -> str:
  177. start_date = env_get("START_DATE", "18-Oct-2025")
  178. try:
  179. datetime.strptime(start_date, "%d-%b-%Y")
  180. except Exception:
  181. print("WARNING: START_DATE is not in 'DD-Mon-YYYY' (e.g., 18-Oct-2025). Using 18-Oct-2025 by default.", file=sys.stderr)
  182. start_date = "18-Oct-2025"
  183. return start_date
  184. def load_state() -> Dict[str, Any]:
  185. path = env_get("STATE_FILE", "curator_state.json")
  186. if not os.path.exists(path):
  187. return {"_path": path, "mailboxes": {}}
  188. try:
  189. with open(path, "r", encoding="utf-8") as f:
  190. data = json.load(f)
  191. data["_path"] = path
  192. if "mailboxes" not in data or not isinstance(data["mailboxes"], dict):
  193. data["mailboxes"] = {}
  194. return data
  195. except Exception:
  196. return {"_path": path, "mailboxes": {}}
  197. def save_state(state: Dict[str, Any]) -> None:
  198. path = state.get("_path", env_get("STATE_FILE", "curator_state.json"))
  199. tmp = path + ".tmp"
  200. with open(tmp, "w", encoding="utf-8") as f:
  201. json.dump({k: v for k, v in state.items() if k != "_path"}, f, ensure_ascii=False, indent=2)
  202. os.replace(tmp, path)
  203. def get_mailbox_key(host: str, user: str, mailbox: str) -> str:
  204. return f"{host}|{user}|{mailbox}"
  205. def uid_search(M: imaplib.IMAP4_SSL, criterion: str) -> List[bytes]:
  206. typ, data = M.uid("search", None, criterion)
  207. if typ != "OK":
  208. raise RuntimeError(f"UID SEARCH failed: {typ} {data}")
  209. return data[0].split()
  210. def search_initial(M: imaplib.IMAP4_SSL, since_date: str) -> List[bytes]:
  211. typ, data = M.search(None, f'(SINCE "{since_date}")')
  212. if typ != "OK":
  213. raise RuntimeError(f"SEARCH failed: {typ} {data}")
  214. ids = data[0].split()
  215. uids: List[bytes] = []
  216. if not ids:
  217. return uids
  218. for i in range(0, len(ids), 500):
  219. batch = ids[i:i+500]
  220. typ2, data2 = M.fetch(b",".join(batch), "(UID)")
  221. if typ2 != "OK":
  222. continue
  223. for part in data2:
  224. if not isinstance(part, tuple):
  225. continue
  226. header = part[0].decode("utf-8", "ignore")
  227. m = re.search(r"UID\s+(\d+)", header)
  228. if m:
  229. uids.append(m.group(1).encode())
  230. return uids
  231. def decode_str(s: Optional[str]) -> str:
  232. if not s:
  233. return ""
  234. try:
  235. return str(make_header(decode_header(s)))
  236. except Exception:
  237. return s
  238. def get_address(msg: email.message.Message) -> (str, str):
  239. from_raw = msg.get("From", "")
  240. name = email.utils.parseaddr(from_raw)[0]
  241. addr = email.utils.parseaddr(from_raw)[1]
  242. return decode_str(name).strip(), addr.strip()
  243. def get_subject(msg: email.message.Message) -> str:
  244. return decode_str(msg.get("Subject", "")).strip()
  245. def get_payload_text(msg: email.message.Message) -> (str, str):
  246. plain_parts = []
  247. html_parts = []
  248. if msg.is_multipart():
  249. for part in msg.walk():
  250. ctype = part.get_content_type()
  251. disp = str(part.get_content_disposition() or "").lower()
  252. if disp == "attachment":
  253. continue
  254. try:
  255. payload = part.get_payload(decode=True)
  256. except Exception:
  257. payload = None
  258. if payload is None:
  259. continue
  260. charset = part.get_content_charset() or "utf-8"
  261. try:
  262. text = payload.decode(charset, errors="replace")
  263. except Exception:
  264. text = payload.decode("utf-8", errors="replace")
  265. if ctype == "text/plain":
  266. plain_parts.append(text)
  267. elif ctype == "text/html":
  268. html_parts.append(text)
  269. else:
  270. payload = msg.get_payload(decode=True) or b""
  271. charset = msg.get_content_charset() or "utf-8"
  272. try:
  273. text = payload.decode(charset, errors="replace")
  274. except Exception:
  275. text = payload.decode("utf-8", errors="replace")
  276. if msg.get_content_type() == "text/html":
  277. html_parts.append(text)
  278. else:
  279. plain_parts.append(text)
  280. return ("\n".join(plain_parts).strip(), "\n".join(html_parts).strip())
  281. def html_to_text(html: str) -> str:
  282. if not html:
  283. return ""
  284. soup = BeautifulSoup(html, "html.parser")
  285. for tag in soup(["script", "style"]):
  286. tag.decompose()
  287. return soup.get_text(separator="\n").strip()
  288. def extract_links(text: str) -> List[str]:
  289. if not text:
  290. return []
  291. urls = re.findall(r"https?://[^\s<>()\"\']+", text, flags=re.IGNORECASE)
  292. seen = set()
  293. out = []
  294. for u in urls:
  295. if u not in seen:
  296. seen.add(u)
  297. out.append(u)
  298. return out
  299. def filter_curator_links(urls: List[str]) -> List[str]:
  300. if not urls:
  301. return []
  302. combined = "|".join(CURATOR_LINK_PATTERNS)
  303. pat = re.compile(combined, re.IGNORECASE)
  304. return [u for u in urls if pat.search(u)]
  305. def detect_key_count(text: str) -> Optional[int]:
  306. if not text:
  307. return None
  308. best = None
  309. for pat in KEY_REQUEST_PATTERNS:
  310. m = re.search(pat, text, flags=re.IGNORECASE)
  311. if m:
  312. if m.lastindex and m.lastindex >= 2 and m.group(1) and m.group(2):
  313. try:
  314. a = int(m.group(1))
  315. b = int(m.group(2))
  316. best = max(a, b)
  317. break
  318. except Exception:
  319. continue
  320. else:
  321. nums = [int(g) for g in m.groups() if g and g.isdigit()]
  322. if nums:
  323. best = max(nums)
  324. break
  325. return best
  326. def looks_like_curator_request(subject: str, body_text: str) -> bool:
  327. blob = f"{subject}\n{body_text}".lower()
  328. return any(k.lower() in blob for k in CURATOR_KEYWORDS)
  329. def fetch_by_uid(M: imaplib.IMAP4_SSL, uid: bytes) -> email.message.Message:
  330. typ, data = M.uid("fetch", uid, "(RFC822)")
  331. if typ != "OK" or not data or not isinstance(data[0], tuple):
  332. raise RuntimeError(f"UID FETCH failed for {uid!r}: {typ} {data}")
  333. raw = data[0][1]
  334. msg = email.message_from_bytes(raw, policy=default_policy)
  335. return msg
  336. def parse_msg_date_bj(msg: email.message.Message) -> str:
  337. raw = msg.get("Date") or ""
  338. try:
  339. dt = parsedate_to_datetime(raw)
  340. if dt is None:
  341. raise ValueError("parsedate_to_datetime returned None")
  342. if dt.tzinfo is None:
  343. dt = dt.replace(tzinfo=timezone.utc)
  344. dt_cst = dt.astimezone(ZoneInfo("Asia/Shanghai"))
  345. return dt_cst.strftime("%Y-%m-%d %H:%M")
  346. except Exception:
  347. return ""
  348. def is_reply_or_forward(msg: email.message.Message, subject: str) -> bool:
  349. # Header-based
  350. if msg.get("In-Reply-To") or msg.get("References"):
  351. return True
  352. # Subject-based
  353. if REPLY_FWD_PREFIX.search(subject):
  354. return True
  355. return False
  356. def fetch_and_parse(M: imaplib.IMAP4_SSL, uid: bytes) -> Dict[str, Any]:
  357. msg = fetch_by_uid(M, uid)
  358. name, addr = get_address(msg)
  359. subject = get_subject(msg)
  360. date_local = parse_msg_date_bj(msg)
  361. reply_flag = is_reply_or_forward(msg, subject)
  362. plain, html = get_payload_text(msg)
  363. merged_text = plain.strip()
  364. if html and (not merged_text or len(merged_text) < 20):
  365. merged_text = html_to_text(html)
  366. links_all = extract_links(plain + "\n" + html)
  367. curator_links = filter_curator_links(links_all)
  368. key_count = detect_key_count(merged_text)
  369. if key_count is None:
  370. key_count = 2 # default when not specified
  371. return {
  372. "uid": uid.decode() if isinstance(uid, (bytes, bytearray)) else str(uid),
  373. "from_name": name or "",
  374. "from_email": addr or "",
  375. "subject": subject,
  376. "date_local": date_local,
  377. "body_preview": (merged_text[:3000] + ("..." if len(merged_text) > 3000 else "")),
  378. "curator_links": curator_links,
  379. "key_count": int(key_count),
  380. "is_reply": reply_flag,
  381. }
  382. def connectivity_test() -> int:
  383. try:
  384. M = imap_login()
  385. try:
  386. mailbox = select_mailbox(M)
  387. uids = uid_search(M, "ALL")
  388. count = len(uids)
  389. if count == 0:
  390. print(f"[TEST] Connected to {mailbox}, but it has no messages.")
  391. return 0
  392. latest_uid = uids[-1]
  393. msg = fetch_by_uid(M, latest_uid)
  394. subject = get_subject(msg)
  395. from_name, from_addr = get_address(msg)
  396. date_local = parse_msg_date_bj(msg)
  397. print("[TEST] IMAP OK.")
  398. print(f"[TEST] Mailbox: {mailbox}")
  399. print(f"[TEST] Total messages: {count}")
  400. print(f"[TEST] Latest UID: {latest_uid.decode()}")
  401. print(f"[TEST] Latest From: {from_name} <{from_addr}>")
  402. print(f"[TEST] Latest Subject: {subject}")
  403. print(f"[TEST] Latest Date (BJ): {date_local}")
  404. return 0
  405. finally:
  406. try:
  407. M.logout()
  408. except Exception:
  409. pass
  410. except Exception as e:
  411. print(f"[TEST] IMAP failed: {e}", file=sys.stderr)
  412. return 2
  413. def run_export(reset_state: bool = False, mark_read: bool = False) -> int:
  414. since_date = imap_date_string()
  415. host = env_get("IMAP_HOST", required=True)
  416. user = env_get("EMAIL_USER", required=True)
  417. min_uid = int(env_get("MIN_UID", "125"))
  418. M = imap_login()
  419. state = load_state()
  420. try:
  421. mailbox = select_mailbox(M)
  422. mailbox_key = get_mailbox_key(host, user, mailbox)
  423. if reset_state and mailbox_key in state["mailboxes"]:
  424. del state["mailboxes"][mailbox_key]
  425. save_state(state)
  426. print(f"[STATE] Reset bookmark for {mailbox_key}")
  427. last_uid = None
  428. if mailbox_key in state["mailboxes"]:
  429. last_uid = state["mailboxes"][mailbox_key].get("last_uid")
  430. if last_uid:
  431. criterion = f"(UID {int(last_uid)+1}:*)"
  432. uids = uid_search(M, criterion)
  433. print(f"[SCAN] Using bookmark last_uid={last_uid}; new UIDs found: {len(uids)}")
  434. else:
  435. uids = search_initial(M, since_date)
  436. print(f"[SCAN] Initial run since {since_date}; candidate UIDs: {len(uids)}")
  437. rows: List[Dict[str, Any]] = []
  438. all_uids_for_mark: list[str] = []
  439. max_seen_uid = int(last_uid) if last_uid else 0
  440. row_no = 1
  441. best_by_email: dict[str, dict] = {}
  442. dup_skipped = 0
  443. for i, uid in enumerate(uids, 1):
  444. try:
  445. uid_int = int(uid)
  446. if uid_int < min_uid:
  447. continue
  448. rec = fetch_and_parse(M, uid)
  449. if uid_int > max_seen_uid:
  450. max_seen_uid = uid_int
  451. # Exclude replies/forwards
  452. if rec["is_reply"]:
  453. continue
  454. if looks_like_curator_request(rec["subject"], rec["body_preview"]):
  455. has_links = bool(rec["curator_links"])
  456. curator_email = norm_email(rec["from_email"])
  457. dedup_key = curator_email if curator_email else f"uid:{rec['uid']}"
  458. # 首选:邮件头中的名字
  459. if rec["from_name"]:
  460. curator_name = rec["from_name"].strip()
  461. else:
  462. # 尝试从正文里提取名字
  463. extracted = extract_name_from_body(rec["body_preview"])
  464. if extracted:
  465. curator_name = extracted
  466. #else:
  467. # # 兜底用邮箱前缀
  468. # curator_name = "Curator"
  469. candidate = {
  470. "uid_int": int(uid),
  471. "record": {
  472. "Mailbox Key": rec["uid"], # UID
  473. "Requested Key Count": rec["key_count"],
  474. "Date": rec["date_local"],
  475. "Curator/Name": curator_name,
  476. "Email": rec["from_email"],
  477. "Subject": rec["subject"],
  478. "Curator/Social Links": ", ".join(rec["curator_links"]) if has_links else "",
  479. "Body (preview)": rec["body_preview"],
  480. "_has_links": has_links,
  481. }
  482. }
  483. all_uids_for_mark.append(int(uid))
  484. prev = best_by_email.get(dedup_key)
  485. if prev is None or candidate["uid_int"] > prev["uid_int"]:
  486. best_by_email[dedup_key] = candidate
  487. else:
  488. dup_skipped += 1
  489. if i % 10 == 0:
  490. pct = (i / len(uids)) * 100
  491. print(f" Processed {pct:.1f}% ({i}/{len(uids)})")
  492. except Exception as e:
  493. print(f"[WARN] Failed to parse UID {uid!r}: {e}", file=sys.stderr)
  494. continue
  495. if mark_read:
  496. done = mark_seen_batch(M, mailbox, all_uids_for_mark, batch_size=500)
  497. print(f"[INFO] Marked {done} message(s) as read in batches.")
  498. rows = []
  499. row_no = 1
  500. selected_uids_for_mark = []
  501. for _, v in best_by_email.items():
  502. rec = v["record"]
  503. rows.append({
  504. "No.": row_no,
  505. "Mailbox Key": rec["Mailbox Key"],
  506. "Requested Key Count": rec["Requested Key Count"],
  507. "Date": rec["Date"],
  508. "Curator/Name": rec["Curator/Name"],
  509. "Email": rec["Email"],
  510. "Subject": rec["Subject"],
  511. "Curator/Social Links": rec["Curator/Social Links"],
  512. "Body (preview)": rec["Body (preview)"],
  513. "_has_links": rec["_has_links"],
  514. })
  515. selected_uids_for_mark.append(rec["Mailbox Key"])
  516. row_no += 1
  517. # Save bookmark even if no rows matched, so daily runs skip already-seen messages
  518. state["mailboxes"][mailbox_key] = {"last_uid": str(max_seen_uid)}
  519. save_state(state)
  520. print(f"[STATE] Updated last_uid={max_seen_uid} for {mailbox_key}")
  521. columns = [
  522. "No.", "Mailbox Key", "Requested Key Count", "Date", "Curator/Name",
  523. "Email", "Subject", "Curator/Social Links", "Body (preview)"
  524. ]
  525. if not rows:
  526. print("No curator key requests matched the filters.")
  527. df = pd.DataFrame(columns=columns)
  528. total_keys = with_links = without_links = 0
  529. else:
  530. df = pd.DataFrame(rows)
  531. df["Requested Key Count"] = pd.to_numeric(df["Requested Key Count"], errors="coerce").fillna(0).astype(int)
  532. total_keys = int(df["Requested Key Count"].sum())
  533. with_links = int(df.loc[df["_has_links"], "Requested Key Count"].sum()) if "_has_links" in df.columns else 0
  534. without_links = total_keys - with_links
  535. # drop helper
  536. if "_has_links" in df.columns:
  537. df = df.drop(columns=["_has_links"])
  538. df = df[columns]
  539. out_path = os.path.abspath("curator_requests.xlsx")
  540. with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
  541. df.to_excel(writer, sheet_name="Requests", index=False)
  542. # Summary printout
  543. print("\n=== SUMMARY ===")
  544. print(f"Total requested keys: {total_keys}")
  545. print(f"With social links: {with_links}")
  546. print(f"Without social links: {without_links}")
  547. print(f"\nExported {len(df)} row(s) to {out_path}")
  548. return 0
  549. finally:
  550. try:
  551. M.logout()
  552. except Exception:
  553. pass
  554. def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
  555. p = argparse.ArgumentParser(description="Export Steam curator key requests to Excel via IMAP.")
  556. p.add_argument("--test", action="store_true", help="Run a quick IMAP connectivity test and exit (does not read/write state).")
  557. p.add_argument("--reset-state", action="store_true", help="Reset stored UID bookmark before running.")
  558. p.add_argument("--mark-read", action="store_true", help="After exporting, mark those emails as read on the IMAP server.")
  559. return p.parse_args(argv)
  560. def main(argv: Optional[List[str]] = None) -> int:
  561. args = parse_args(argv)
  562. if args.test:
  563. return connectivity_test() # no state read/write
  564. return run_export(reset_state=args.reset_state, mark_read=args.mark_read)
  565. if __name__ == "__main__":
  566. raise SystemExit(main())