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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
|
||||
请妥善保管,勿轻示他人。
|
||||
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user