feat: daily collection 6/12 - new docs, industry report, .gitignore cleanup

- Collected 9 new messages from dc group and innovation group
- Fetched 6 new Feishu docs (黄静雯/陈楚真/韦译/夏莲/莫润麟/张家振)
- Downloaded 狱国争霸分析拆解.html file attachment
- Saved 6/12 game industry daily report from 韩丹
- Updated .gitignore to exclude temp scripts, images, downloaded files
- Added utility scripts (group members extraction, invitation generation)
This commit is contained in:
Evilom
2026-06-12 10:32:24 +08:00
parent 3b77605591
commit 64ac71a8ed
9 changed files with 1453 additions and 9 deletions
+97
View File
@@ -0,0 +1,97 @@
"""批量生成邀请函图片:读取 invitation_config.json 模板 + group_members_with_account.csv"""
import csv
import json
import os
import sys
from PIL import Image, ImageDraw, ImageFont
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
BG_PATH = os.path.join(DATA_DIR, "bg.png")
FONT_DIR = DATA_DIR
CONFIG_PATH = os.path.join(DATA_DIR, "invitation_config.json")
CSV_PATH = os.path.join(DATA_DIR, "group_members_with_account.csv")
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output", "invitations")
def hex_to_rgb(h):
h = h.lstrip('#')
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def resolve_font(name):
local = os.path.join(FONT_DIR, name)
if os.path.isfile(local):
return local
sys_font = os.path.join(r"C:\Windows\Fonts", name)
if os.path.isfile(sys_font):
return sys_font
return os.path.join(FONT_DIR, "WenDaoGeTeSong.ttf")
def generate_image(cfg, name, account, password):
img = Image.open(BG_PATH).copy()
draw = ImageDraw.Draw(img)
font_path = resolve_font(cfg.get("font", "WenDaoGeTeSong.ttf"))
sw = cfg.get("stroke_width", 3)
sc = hex_to_rgb(cfg.get("stroke_color", "#000000"))
size = cfg.get("size", 30)
font = ImageFont.truetype(font_path, size)
color = hex_to_rgb(cfg.get("color", "#ffffff"))
x = cfg.get("x", 510)
y = cfg.get("y", 1055)
text = cfg.get("text", "")
text = text.replace("{name}", name).replace("{account}", account).replace("{password}", password)
line_height = cfg.get("line_height", 1.5)
spacing = int(size * (line_height - 1.0))
align = cfg.get("align", "center")
anchor_map = {"left": "lt", "center": "mt", "right": "rt"}
anchor = anchor_map.get(align, "mt")
lines = text.split("\n")
cur_y = y
for line in lines:
draw.text(
(x, cur_y), line, fill=color, font=font, anchor=anchor,
stroke_width=sw, stroke_fill=sc
)
cur_y += size + spacing
return img
def main():
with open(CONFIG_PATH, encoding="utf-8") as f:
cfg = json.load(f)
with open(CSV_PATH, encoding="utf-8") as f:
rows = list(csv.DictReader(f))
os.makedirs(OUTPUT_DIR, exist_ok=True)
total = 0
skipped = 0
for row in rows:
name = row.get("姓名", "").strip()
account = row.get("账号", "").strip()
password = row.get("密码", "").strip()
if not account or not password:
skipped += 1
continue
img = generate_image(cfg, name, account, password)
output_path = os.path.join(OUTPUT_DIR, f"invite_{account}.jpg")
img.convert("RGB").save(output_path, format="JPEG", quality=75)
total += 1
print(f"Done. Generated {total} images, skipped {skipped} (missing account/password).")
print(f"Output: {OUTPUT_DIR}")
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
"""从钉钉群拉取全部成员并查询工号/邮箱/手机等详细信息,输出 CSV"""
import csv
import json
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
from config import DWS
GROUP_ID = "cidrLgqKZUgbJ9PdVHgQQIS9g=="
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
OUTPUT_CSV = os.path.join(OUTPUT_DIR, "group_members_detail.csv")
OUTPUT_JSON = os.path.join(OUTPUT_DIR, "group_members_all.json")
def run_dws(args, timeout=30):
cmd = [DWS] + args + ["-f", "json"]
result = subprocess.run(cmd, capture_output=True, timeout=timeout)
out = result.stdout.decode("utf-8", errors="replace")
for i, ch in enumerate(out):
if ch == "{":
return json.loads(out[i:])
return None
def fetch_all_members():
all_members = []
cursor = "0"
page = 0
while True:
page += 1
args = ["chat", "group", "members", "list", "--id", GROUP_ID, "--cursor", cursor]
data = run_dws(args, timeout=60)
if not data or not data.get("success"):
print(f" page {page} failed: {data}")
break
result = data.get("result", {})
members = result.get("list", [])
all_members.extend(members)
print(f" page {page}: {len(members)} members (total {len(all_members)})")
if not result.get("hasMore"):
break
cursor = str(result.get("nextCursor", ""))
if not cursor:
break
time.sleep(0.3)
return all_members
def search_user_id(name, open_dingtalk_id):
"""通过姓名搜索获取 userId"""
args = ["contact", "user", "search", "--query", name]
data = run_dws(args, timeout=15)
if data and data.get("success"):
results = data.get("result", [])
for r in results:
if r.get("openDingTalkId") == open_dingtalk_id:
return r.get("userId", "")
if r.get("name") == name:
return r.get("userId", "")
if results:
return results[0].get("userId", "")
return ""
def batch_get_user_details(user_ids):
"""批量获取用户详情"""
profiles = {}
batch_size = 20
for i in range(0, len(user_ids), batch_size):
batch = [uid for uid in user_ids[i:i+batch_size] if uid]
if not batch:
continue
ids_str = ",".join(batch)
args = ["contact", "user", "get", "--ids", ids_str]
data = run_dws(args, timeout=30)
if data and data.get("success"):
result = data.get("result", [])
if isinstance(result, list):
for u in result:
model = u.get("orgEmployeeModel", {})
uid = model.get("orgUserId", "")
if uid:
profiles[uid] = model
print(f" batch {i//batch_size+1}/{(len(user_ids)+batch_size-1)//batch_size}: got {len(profiles)} profiles")
time.sleep(0.3)
return profiles
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
print("=" * 50)
print("Step 1: Fetch all group members")
print("=" * 50)
members = fetch_all_members()
print(f"\nTotal: {len(members)} members")
with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
json.dump(members, f, ensure_ascii=False, indent=2)
print("\n" + "=" * 50)
print("Step 2: Resolve userId for each member via search")
print("=" * 50)
user_id_map = {} # openDingtalkId -> userId
for idx, m in enumerate(members, 1):
name = m.get("memberEmpName", "")
oid = m.get("openDingtalkId", "")
if name and oid:
uid = search_user_id(name, oid)
if uid:
user_id_map[oid] = uid
if idx % 50 == 0:
print(f" resolved {len(user_id_map)}/{idx} userIds")
time.sleep(0.15)
print(f" resolved {len(user_id_map)}/{len(members)} userIds total")
print("\n" + "=" * 50)
print("Step 3: Batch query user profiles")
print("=" * 50)
all_user_ids = list(user_id_map.values())
profiles = batch_get_user_details(all_user_ids)
print("\n" + "=" * 50)
print("Step 4: Generate CSV")
print("=" * 50)
fieldnames = ["序号", "姓名", "昵称", "工号", "企业邮箱", "职位", "部门", "userId", "角色", "openDingtalkId"]
rows = []
for idx, m in enumerate(members, 1):
oid = m.get("openDingtalkId", "")
name = m.get("memberEmpName", "")
nick = m.get("memberNick", "")
role = m.get("memberRoleDesc", "")
uid = user_id_map.get(oid, "")
p = profiles.get(uid, {})
job_number = p.get("jobNumber", "")
email = p.get("orgAuthEmail", "")
title = p.get("orgTitle", "")
depts = p.get("depts", [])
dept_name = "/".join(d.get("deptName", "") for d in depts) if depts else ""
rows.append({
"序号": idx, "姓名": name, "昵称": nick, "工号": job_number,
"企业邮箱": email, "职位": title, "部门": dept_name,
"userId": uid, "角色": role, "openDingtalkId": oid,
})
with open(OUTPUT_CSV, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print(f"\nCSV saved: {OUTPUT_CSV}")
print(f"Total: {len(rows)} rows")
has_job = sum(1 for r in rows if r["工号"])
has_email = sum(1 for r in rows if r["企业邮箱"])
has_dept = sum(1 for r in rows if r["部门"])
print(f"有工号: {has_job}, 有邮箱: {has_email}, 有部门: {has_dept}")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
import csv, os
CSV_PATH = r"D:\desktop\feishu_docs_summary\data\group_members_with_account.csv"
HTML_PATH = r"D:\desktop\feishu_docs_summary\output\group_members_with_account.html"
rows = []
with open(CSV_PATH, encoding="utf-8-sig") as f:
for r in csv.DictReader(f):
rows.append(r)
matched = sum(1 for r in rows if r.get("密码"))
html = []
html.append('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">')
html.append('<title>厦门点触科技 - 群成员账号信息</title>')
html.append("""<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"Microsoft YaHei",sans-serif;background:#f5f6fa;padding:20px}
h1{text-align:center;margin-bottom:6px;font-size:22px;color:#2d3436}
.stats{text-align:center;color:#636e72;margin-bottom:16px;font-size:14px}
.sb{text-align:center;margin-bottom:16px}
.sb input{width:400px;padding:8px 14px;border:1px solid #ddd;border-radius:6px;font-size:14px}
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
th{background:#0984e3;color:#fff;padding:10px 12px;text-align:left;font-size:13px;cursor:pointer;user-select:none;white-space:nowrap}
th:hover{background:#0770c2}
td{padding:8px 12px;border-bottom:1px solid #eee;font-size:13px}
tr:hover{background:#f0f7ff}
tr:nth-child(even){background:#fafbfc}
tr:nth-child(even):hover{background:#f0f7ff}
.m{color:#d63031;font-style:italic}
</style></head><body>""")
html.append('<h1>厦门点触科技股份有限公司 - 群成员账号信息</h1>')
html.append(f'<p class="stats">共 <b>{len(rows)}</b> 人 | 已匹配账号: <b>{matched}</b> | 未匹配: <b>{len(rows)-matched}</b></p>')
html.append('<div class="sb"><input type="text" id="q" placeholder="搜索姓名、工号、邮箱、部门..." oninput="ft()"></div>')
html.append('<table id="t"><thead><tr>')
for i, h in enumerate(["序号","姓名","账号","密码","企业邮箱","部门","职位"]):
html.append(f'<th onclick="st({i})">{h}</th>')
html.append('</tr></thead><tbody>')
for r in rows:
acct = r.get("账号","")
pwd = r.get("密码","")
pwd_d = str(pwd) if pwd else '<span class="m">-</span>'
acct_d = acct if acct else '<span class="m">-</span>'
html.append(f'<tr><td>{r.get("序号","")}</td><td>{r.get("姓名","")}</td><td>{acct_d}</td><td>{pwd_d}</td><td>{r.get("企业邮箱","")}</td><td>{r.get("部门","")}</td><td>{r.get("职位","")}</td></tr>')
html.append("""</tbody></table>
<script>
function ft(){var q=document.getElementById("q").value.toLowerCase(),rs=document.getElementById("t").tBodies[0].rows;for(var i=0;i<rs.length;i++)rs[i].style.display=rs[i].textContent.toLowerCase().indexOf(q)>-1?"":"none"}
var sd={};function st(c){var t=document.getElementById("t"),b=t.tBodies[0],rs=Array.from(b.rows);sd[c]=!sd[c];rs.sort(function(a,b){var x=a.cells[c].textContent.trim(),y=b.cells[c].textContent.trim(),nx=parseFloat(x),ny=parseFloat(y);if(!isNaN(nx)&&!isNaN(ny))return sd[c]?nx-ny:ny-nx;return sd[c]?x.localeCompare(y,"zh"):y.localeCompare(x,"zh")});rs.forEach(function(r){b.appendChild(r)})}
</script></body></html>""")
with open(HTML_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(html))
print(f"HTML saved: {HTML_PATH}")
+62
View File
@@ -0,0 +1,62 @@
"""根据 bg.png 模板生成带姓名/账号/密码的邀请函图片"""
from PIL import Image, ImageDraw, ImageFont
import json
import os
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
BG_PATH = os.path.join(DATA_DIR, "bg.png")
FONT_PATH = os.path.join(DATA_DIR, "WenDaoGeTeSong.ttf")
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output", "invitations")
# 文字位置配置(可通过 invitation_tuner.html 调整后粘贴回来)
CONFIG = {
"name": {"x": 582, "y": 1192, "size": 52, "color": "#ffd586"},
"account": {"x": 572, "y": 1293, "size": 44, "color": "#ffffff"},
"password": {"x": 575, "y": 1394, "size": 44, "color": "#ffffff"},
}
def hex_to_rgb(hex_color):
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def generate_invitation(name, account, password, output_path=None):
img = Image.open(BG_PATH).copy()
draw = ImageDraw.Draw(img)
fields = {
"name": name,
"account": account,
"password": password,
}
for field_key, text in fields.items():
cfg = CONFIG[field_key]
font = ImageFont.truetype(FONT_PATH, cfg["size"])
color = hex_to_rgb(cfg["color"])
# textAlign center + textBaseline top 对应 Pillow 的 anchor='mt'
draw.text((cfg["x"], cfg["y"]), text, fill=color, font=font, anchor="mt")
if not output_path:
os.makedirs(OUTPUT_DIR, exist_ok=True)
safe_name = name.replace("/", "_").replace("\\", "_")
output_path = os.path.join(OUTPUT_DIR, f"invite_{safe_name}.png")
img.save(output_path, quality=95)
return output_path
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--name", default="测试用户")
parser.add_argument("--account", default="20250580")
parser.add_argument("--password", default="123456")
parser.add_argument("--output", default=None)
args = parser.parse_args()
path = generate_invitation(args.name, args.account, args.password, args.output)
print(f"Generated: {path}")
print(f"Font: {FONT_PATH}")
+209
View File
@@ -0,0 +1,209 @@
"""根据群成员从账号数据库匹配账号密码,逐个私聊发送"""
import csv
import json
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
from config import DWS
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
ACCT_DB = os.path.join(DATA_DIR, "group_members_account.xlsx")
LOG_FILE = os.path.join(DATA_DIR, "send_account_log.json")
DEFAULT_GROUP_NAME = "账号发放测试"
def run_dws(args, timeout=30):
cmd = [DWS] + args + ["-f", "json"]
result = subprocess.run(cmd, capture_output=True, timeout=timeout)
out = result.stdout.decode("utf-8", errors="replace")
for i, ch in enumerate(out):
if ch == "{":
return json.loads(out[i:])
return None
def load_account_db():
import openpyxl
wb = openpyxl.load_workbook(ACCT_DB, read_only=True)
ws = wb["账号信息"]
acct_map = {}
for r in list(ws.iter_rows(values_only=True))[1:]:
t, acct, pwd = r[0], r[1], r[2]
if t is not None and acct is not None:
try:
acct_map[str(int(acct))] = str(int(pwd)) if pwd else ""
except:
acct_map[str(acct)] = str(pwd)
wb.close()
return acct_map
def fetch_all_members(group_id):
all_members = []
cursor = "0"
while True:
args = ["chat", "group", "members", "list", "--id", group_id, "--cursor", cursor]
data = run_dws(args, timeout=60)
if not data or not data.get("success"):
break
result = data.get("result", {})
all_members.extend(result.get("list", []))
if not result.get("hasMore"):
break
cursor = str(result.get("nextCursor", ""))
if not cursor:
break
time.sleep(0.3)
return all_members
def build_message(name, account, password):
return f"""## 隐秘之潮 - 试玩活动
{name},你好!
项目试玩活动即将开启,以下是你的专属测试账号:
| 项目 | 信息 |
|------|------|
| **账号** | {account} |
| **密码** | {password} |
请妥善保管,勿转发给他人。
有问题欢迎在群里反馈。祝你玩得开心!"""
def send_message(open_dingtalk_id, title, text, dry_run=False):
if dry_run:
print(f" [DRY RUN] -> {open_dingtalk_id}")
return {"success": True, "dry_run": True}
args = [
"chat", "message", "send",
"--open-dingtalk-id", open_dingtalk_id,
"--title", title,
"--text", text,
]
return run_dws(args, timeout=15)
def main():
import argparse
parser = argparse.ArgumentParser(description="群成员账号发放工具")
parser.add_argument("--group", type=str, help="群 openConversationId")
parser.add_argument("--group-name", type=str, default=DEFAULT_GROUP_NAME, help="群名 (默认: 账号发放测试)")
parser.add_argument("--dry-run", action="store_true", help="只预览不发送")
parser.add_argument("--limit", type=int, default=0, help="限制发送人数")
args = parser.parse_args()
group_id = args.group
if not group_id and args.group_name:
print(f"搜索群: {args.group_name}")
data = run_dws(["chat", "search", "--query", args.group_name], timeout=15)
if data and data.get("success"):
groups = data["result"].get("groups", [])
if groups:
group_id = groups[0]["openConversationId"]
print(f" 找到: {groups[0]['title']} ({group_id})")
else:
print(" 未找到群")
return
if not group_id:
print("请指定 --group <id> 或 --group-name <名称>")
return
print("加载账号数据库...")
acct_db = load_account_db()
print(f" {len(acct_db)} 个账号")
print("拉取群成员...")
members = fetch_all_members(group_id)
print(f" {len(members)}")
print("解析成员工号...")
member_accounts = []
no_match = []
for idx, m in enumerate(members, 1):
name = m.get("memberEmpName", "")
oid = m.get("openDingtalkId", "")
nick = m.get("memberNick", "")
if not name or not oid:
no_match.append({"name": name, "reason": "无姓名或ID"})
continue
args_search = ["contact", "user", "search", "--query", name]
data = run_dws(args_search, timeout=15)
user_id = ""
if data and data.get("success"):
for r in data.get("result", []):
if r.get("openDingTalkId") == oid:
user_id = r.get("userId", "")
break
job_number = ""
if user_id:
args_get = ["contact", "user", "get", "--ids", user_id]
data2 = run_dws(args_get, timeout=15)
if data2 and data2.get("success"):
for u in data2.get("result", []):
model = u.get("orgEmployeeModel", {})
job_number = str(model.get("jobNumber", ""))
break
if job_number and job_number in acct_db:
pwd = acct_db[job_number]
member_accounts.append({
"name": name, "nick": nick,
"openDingtalkId": oid, "userId": user_id,
"account": job_number, "password": pwd,
})
else:
no_match.append({"name": name, "job_number": job_number, "reason": "工号无匹配"})
if idx % 50 == 0:
print(f" {len(member_accounts)}/{idx} 已匹配")
time.sleep(0.15)
print(f"\n匹配结果: {len(member_accounts)} 人有账号, {len(no_match)} 人无匹配")
if args.limit > 0:
member_accounts = member_accounts[:args.limit]
print(f"限制发送: {args.limit}")
title = "隐秘之潮 - 试玩活动"
success_count = 0
fail_count = 0
log = []
print(f"\n{'[DRY RUN] ' if args.dry_run else ''}开始发送...")
for idx, ma in enumerate(member_accounts, 1):
text = build_message(ma["name"], ma["account"], ma["password"])
result = send_message(ma["openDingtalkId"], title, text, dry_run=args.dry_run)
status = "ok" if result and result.get("success") else "fail"
if status == "ok":
success_count += 1
else:
fail_count += 1
log.append({"name": ma["name"], "account": ma["account"], "status": status, "response": result})
print(f" [{idx}/{len(member_accounts)}] {ma['name']} ({ma['account']}) -> {status}")
if not args.dry_run:
time.sleep(0.5)
with open(LOG_FILE, "w", encoding="utf-8") as f:
json.dump(log, f, ensure_ascii=False, indent=2)
print(f"\n完成! 成功: {success_count}, 失败: {fail_count}")
print(f"日志: {LOG_FILE}")
if no_match:
print(f"\n未匹配人员 ({len(no_match)}):")
for nm in no_match[:10]:
print(f" {nm['name']}: {nm.get('reason','')}")
if __name__ == "__main__":
main()
+373
View File
@@ -0,0 +1,373 @@
"""批量发送邀请卡图片:上传到 ImgBB 图床 → 私聊 markdown 图片消息发给群成员
用法:
python send_invitations.py --upload-only # 预上传所有图片到图床,按工号缓存 URL
python send_invitations.py # 发送(跳过已发放,自动检查 URL)
python send_invitations.py --limit 2 --dry-run # 试运行
python send_invitations.py --status # 查看发放状态
python send_invitations.py --reset <工号> # 重置某人发放状态(允许重新发放)
"""
import base64
import csv
import json
import os
import subprocess
import sys
import time
from datetime import datetime
import requests
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import DWS
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
CSV_PATH = os.path.join(DATA_DIR, "group_members_with_account.csv")
INVITATION_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output", "invitations")
LOG_FILE = os.path.join(DATA_DIR, "send_invitation_log.json")
URL_CACHE_FILE = os.path.join(DATA_DIR, "invitation_urls.json")
STATUS_FILE = os.path.join(DATA_DIR, "invitation_delivery_status.json")
DEFAULT_GROUP_NAME = "账号发放测试"
IMGBB_API_KEY = "3d6db51a136a8993c6d14e2c14ccf3bc"
IMGBB_API_URL = "https://api.imgbb.com/1/upload"
def run_dws(args, timeout=30):
cmd = [DWS] + args + ["-f", "json"]
result = subprocess.run(cmd, capture_output=True, timeout=timeout)
out = result.stdout.decode("utf-8", errors="replace")
for i, ch in enumerate(out):
if ch == "{":
return json.loads(out[i:])
return None
def find_group(group_name):
data = run_dws(["chat", "search", "--query", group_name], timeout=15)
if data and data.get("success"):
groups = data["result"].get("groups", [])
if groups:
return groups[0]["openConversationId"]
return None
def fetch_all_members(group_id):
all_members = []
cursor = "0"
while True:
data = run_dws(["chat", "group", "members", "list", "--id", group_id, "--cursor", cursor], timeout=60)
if not data or not data.get("success"):
break
result = data.get("result", {})
all_members.extend(result.get("list", []))
if not result.get("hasMore"):
break
cursor = str(result.get("nextCursor", ""))
if not cursor:
break
time.sleep(0.3)
return all_members
def upload_to_imgbb(image_path):
with open(image_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
payload = {"key": IMGBB_API_KEY, "image": image_data}
for attempt in range(3):
try:
resp = requests.post(IMGBB_API_URL, data=payload, timeout=30, proxies={"http": None, "https": None})
result = resp.json()
if result.get("success"):
return result["data"]["url"]
return None
except Exception as e:
if attempt < 2:
time.sleep(2 * (attempt + 1))
else:
print(f" 上传异常: {e}")
return None
def check_url(url):
try:
resp = requests.head(url, timeout=10, allow_redirects=True, proxies={"http": None, "https": None})
return resp.status_code == 200 and "image" in resp.headers.get("content-type", "")
except Exception:
return False
def load_json(path, default=None):
if default is None:
default = {}
if os.path.isfile(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
return default
def save_json(path, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_url_cache():
return load_json(URL_CACHE_FILE)
def save_url_cache(cache):
save_json(URL_CACHE_FILE, cache)
def load_status():
return load_json(STATUS_FILE)
def save_status(status):
save_json(STATUS_FILE, status)
def send_invitation(open_dingtalk_id, image_url, name, account, password, dry_run=False):
if dry_run:
return {"success": True, "dry_run": True}
title = "隐秘之潮 - 邀请函"
text = f"""{name},你好!
今诚邀阁下步入《隐秘之潮》,赴此内部试玩之旅。
此函所附,为阁下本次试玩凭证:
测试账号:{account}
测试密码:{password}
请妥善保管,勿轻示他人。
![邀请函]({image_url})"""
return run_dws([
"chat", "message", "send",
"--open-dingtalk-id", open_dingtalk_id,
"--title", title,
"--text", text,
], timeout=15)
def find_image(account):
for ext in ("jpg", "png"):
p = os.path.join(INVITATION_DIR, f"invite_{account}.{ext}")
if os.path.isfile(p):
return p
return None
def load_csv_members():
with open(CSV_PATH, encoding="utf-8") as f:
rows = list(csv.DictReader(f))
members = {}
for row in rows:
name = row.get("姓名", "").strip()
account = row.get("账号", "").strip()
password = row.get("密码", "").strip()
if name and account and password:
members[name] = (account, password)
return members
def do_upload_all():
"""预上传所有图片到图床,按工号缓存 URL"""
members = load_csv_members()
cache = load_url_cache()
total = 0
uploaded = 0
skipped = 0
no_img = 0
for name, (account, _) in members.items():
img_path = find_image(account)
if not img_path:
no_img += 1
continue
total += 1
cached = cache.get(account, {})
url = cached.get("url")
if url and check_url(url):
skipped += 1
continue
url = upload_to_imgbb(img_path)
if url:
cache[account] = {"name": name, "url": url, "path": img_path}
uploaded += 1
print(f" [{uploaded}] {account} {name} -> {url}")
else:
print(f" [FAIL] {account} {name} -> 上传失败")
time.sleep(0.3)
save_url_cache(cache)
print(f"\n预上传完成! 总计: {total}, 新上传: {uploaded}, 已有: {skipped}, 无图片: {no_img}")
print(f"URL 缓存: {URL_CACHE_FILE}")
def do_send(group_name, dry_run=False, limit=0, target_name=None):
"""发送模式:匹配群成员 → 跳过已发放 → 发送 → 标记"""
cache = load_url_cache()
status = load_status()
member_info = load_csv_members()
print(f"CSV 中有账号的成员: {len(member_info)}")
print(f"搜索群: {group_name}")
group_id = find_group(group_name)
if not group_id:
print("未找到群")
return
print(f" 群ID: {group_id}")
print("拉取群成员...")
members = fetch_all_members(group_id)
print(f" {len(members)}")
# 匹配
to_send = []
skipped_delivered = 0
for m in members:
name = m.get("memberEmpName", "")
oid = m.get("openDingtalkId", "")
if not name or not oid or name not in member_info:
continue
account, password = member_info[name]
if not find_image(account):
continue
# 跳过已发放
if status.get(account, {}).get("delivered"):
skipped_delivered += 1
continue
to_send.append({"name": name, "account": account, "password": password, "oid": oid})
print(f"匹配到 {len(to_send) + skipped_delivered} 人有邀请卡")
if skipped_delivered > 0:
print(f" 其中 {skipped_delivered} 人已发放,跳过")
# 指定姓名过滤
if target_name:
to_send = [t for t in to_send if t["name"] == target_name]
if not to_send:
print(f"未找到未发放的成员: {target_name}")
return
if limit > 0:
to_send = to_send[:limit]
if not to_send:
print("无需发放,全部已完成")
return
# 发送
success = 0
fail = 0
log = []
print(f"\n{'[DRY RUN] ' if dry_run else ''}开始发送...")
for idx, item in enumerate(to_send, 1):
name = item["name"]
account = item["account"]
oid = item["oid"]
# 按工号查 URL
cached = cache.get(account, {})
image_url = cached.get("url")
if not image_url or not check_url(image_url):
img_path = find_image(account)
image_url = upload_to_imgbb(img_path)
if not image_url:
print(f" [{idx}/{len(to_send)}] {name}({account}) -> 上传失败")
log.append({"name": name, "account": account, "status": "upload_fail"})
fail += 1
continue
cache[account] = {"name": name, "url": image_url, "path": img_path}
save_url_cache(cache)
result = send_invitation(oid, image_url, name, account, item["password"], dry_run=dry_run)
ok = result and result.get("success")
if ok:
success += 1
# 标记已发放
if not dry_run:
status[account] = {
"name": name,
"delivered": True,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
save_status(status)
else:
fail += 1
log.append({"name": name, "account": account, "image_url": image_url, "status": "ok" if ok else "fail", "response": result})
print(f" [{idx}/{len(to_send)}] {name}({account}) -> {'ok' if ok else 'fail'}")
if not dry_run:
time.sleep(0.5)
save_json(LOG_FILE, log)
print(f"\n完成! 成功: {success}, 失败: {fail}")
print(f"日志: {LOG_FILE}")
def do_status():
"""查看发放状态"""
status = load_status()
members = load_csv_members()
total_with_account = len(members)
delivered = sum(1 for v in status.values() if v.get("delivered"))
not_delivered = total_with_account - delivered
print(f"有账号的成员: {total_with_account}")
print(f"已发放: {delivered}")
print(f"未发放: {not_delivered}")
if status:
print(f"\n最近发放记录:")
recent = sorted(status.items(), key=lambda x: x[1].get("time", ""), reverse=True)
for account, info in recent[:10]:
print(f" {account} {info.get('name', '')} -> {info.get('time', '')}")
def do_reset(account):
"""重置某人发放状态"""
status = load_status()
if account in status:
name = status[account].get("name", "")
del status[account]
save_status(status)
print(f"已重置: {account} {name}")
else:
print(f"未找到工号 {account} 的发放记录")
def main():
import argparse
parser = argparse.ArgumentParser(description="邀请卡图片批量发放")
parser.add_argument("--group-name", type=str, default=DEFAULT_GROUP_NAME)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--upload-only", action="store_true", help="仅预上传所有图片到图床,不发送")
parser.add_argument("--status", action="store_true", help="查看发放状态")
parser.add_argument("--reset", type=str, metavar="工号", help="重置某人发放状态")
parser.add_argument("--name", type=str, help="指定发送给某人(姓名)")
args = parser.parse_args()
if args.upload_only:
do_upload_all()
elif args.status:
do_status()
elif args.reset:
do_reset(args.reset)
else:
do_send(args.group_name, dry_run=args.dry_run, limit=args.limit, target_name=args.name)
if __name__ == "__main__":
main()