feat: add dashboard friend circle management
This commit is contained in:
390
admin/dashboard/blueprints/friend_circle.py
Normal file
390
admin/dashboard/blueprints/friend_circle.py
Normal file
@@ -0,0 +1,390 @@
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
from loguru import logger
|
||||
|
||||
from .auth import login_required
|
||||
|
||||
friend_circle_bp = Blueprint("friend_circle", __name__)
|
||||
|
||||
|
||||
def _run_client_coro(server, coro, timeout=90):
|
||||
future = asyncio.run_coroutine_threadsafe(coro, server.robot.ipad_loop)
|
||||
return future.result(timeout=timeout)
|
||||
|
||||
|
||||
def _safe_text(value, default=""):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, dict):
|
||||
if "string" in value:
|
||||
return _safe_text(value.get("string"), default)
|
||||
if "text" in value:
|
||||
return _safe_text(value.get("text"), default)
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _walk_nodes(payload):
|
||||
if isinstance(payload, dict):
|
||||
yield payload
|
||||
for value in payload.values():
|
||||
yield from _walk_nodes(value)
|
||||
elif isinstance(payload, list):
|
||||
for item in payload:
|
||||
yield from _walk_nodes(item)
|
||||
|
||||
|
||||
def _extract_list(values):
|
||||
if isinstance(values, list):
|
||||
return values
|
||||
if isinstance(values, dict):
|
||||
for key in ("list", "List", "items", "Items"):
|
||||
if isinstance(values.get(key), list):
|
||||
return values.get(key)
|
||||
return []
|
||||
|
||||
|
||||
def _parse_media_from_xml(root):
|
||||
media_items = []
|
||||
for media in root.findall(".//mediaList/media"):
|
||||
media_items.append({
|
||||
"url": _safe_text(media.findtext("url")),
|
||||
"thumb": _safe_text(media.findtext("thumb")),
|
||||
"id": _safe_text(media.findtext("id"))
|
||||
})
|
||||
return [item for item in media_items if item.get("url") or item.get("thumb")]
|
||||
|
||||
|
||||
def _parse_timeline_xml(xml_text):
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return {}
|
||||
|
||||
create_time = _safe_text(root.findtext("createTime"))
|
||||
timestamp = ""
|
||||
if create_time.isdigit():
|
||||
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
return {
|
||||
"id": _safe_text(root.findtext("id")),
|
||||
"author_wxid": _safe_text(root.findtext("username")),
|
||||
"content": _safe_text(root.findtext("contentDesc")),
|
||||
"timestamp": timestamp,
|
||||
"media": _parse_media_from_xml(root)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_comment(comment):
|
||||
comment_id = (
|
||||
_safe_text(comment.get("CommnetId"))
|
||||
or _safe_text(comment.get("CommentId"))
|
||||
or _safe_text(comment.get("id"))
|
||||
)
|
||||
return {
|
||||
"comment_id": comment_id,
|
||||
"author_wxid": (
|
||||
_safe_text(comment.get("UserName"))
|
||||
or _safe_text(comment.get("username"))
|
||||
or _safe_text(comment.get("Wxid"))
|
||||
),
|
||||
"author_name": (
|
||||
_safe_text(comment.get("NickName"))
|
||||
or _safe_text(comment.get("nickname"))
|
||||
or _safe_text(comment.get("DisplayName"))
|
||||
),
|
||||
"content": (
|
||||
_safe_text(comment.get("Content"))
|
||||
or _safe_text(comment.get("content"))
|
||||
or _safe_text(comment.get("Text"))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_like(like):
|
||||
return {
|
||||
"author_wxid": (
|
||||
_safe_text(like.get("UserName"))
|
||||
or _safe_text(like.get("username"))
|
||||
or _safe_text(like.get("Wxid"))
|
||||
),
|
||||
"author_name": (
|
||||
_safe_text(like.get("NickName"))
|
||||
or _safe_text(like.get("nickname"))
|
||||
or _safe_text(like.get("DisplayName"))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_post(node, server):
|
||||
xml_payload = ""
|
||||
for key in ("Content", "Xml", "ObjectDesc", "TimelineObject"):
|
||||
value = node.get(key)
|
||||
if isinstance(value, str) and "<TimelineObject" in value:
|
||||
xml_payload = value
|
||||
break
|
||||
|
||||
xml_data = _parse_timeline_xml(xml_payload) if xml_payload else {}
|
||||
object_id = (
|
||||
_safe_text(node.get("Id"))
|
||||
or _safe_text(node.get("id"))
|
||||
or xml_data.get("id", "")
|
||||
)
|
||||
author_wxid = (
|
||||
_safe_text(node.get("UserName"))
|
||||
or _safe_text(node.get("Username"))
|
||||
or _safe_text(node.get("Towxid"))
|
||||
or xml_data.get("author_wxid", "")
|
||||
)
|
||||
author_name = (
|
||||
_safe_text(node.get("NickName"))
|
||||
or _safe_text(node.get("nickname"))
|
||||
or server.contact_manager.get_nickname(author_wxid)
|
||||
)
|
||||
content = (
|
||||
_safe_text(node.get("ContentDesc"))
|
||||
or _safe_text(node.get("contentDesc"))
|
||||
or _safe_text(node.get("Content"))
|
||||
)
|
||||
if "<TimelineObject" in content:
|
||||
content = ""
|
||||
content = content or xml_data.get("content", "")
|
||||
|
||||
create_time = (
|
||||
_safe_text(node.get("CreateTime"))
|
||||
or _safe_text(node.get("createTime"))
|
||||
)
|
||||
timestamp = xml_data.get("timestamp", "")
|
||||
if not timestamp and create_time.isdigit():
|
||||
timestamp = datetime.fromtimestamp(int(create_time)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
media = []
|
||||
for list_key in ("MediaList", "mediaList", "Medias", "medias"):
|
||||
media_list = _extract_list(node.get(list_key))
|
||||
for item in media_list:
|
||||
media.append({
|
||||
"url": _safe_text(item.get("Url")) or _safe_text(item.get("url")),
|
||||
"thumb": _safe_text(item.get("ThumbUrl")) or _safe_text(item.get("thumb")),
|
||||
"id": _safe_text(item.get("Id")) or _safe_text(item.get("id"))
|
||||
})
|
||||
if not media:
|
||||
media = xml_data.get("media", [])
|
||||
|
||||
comment_nodes = []
|
||||
like_nodes = []
|
||||
for key in ("CommentList", "Comments", "commentList", "comments"):
|
||||
comment_nodes.extend(_extract_list(node.get(key)))
|
||||
for key in ("LikeList", "Likes", "likeList", "likes"):
|
||||
like_nodes.extend(_extract_list(node.get(key)))
|
||||
|
||||
return {
|
||||
"id": object_id,
|
||||
"author_wxid": author_wxid,
|
||||
"author_name": author_name,
|
||||
"author_avatar": server.contact_manager.get_head_image(author_wxid),
|
||||
"content": content,
|
||||
"timestamp": timestamp,
|
||||
"media": [item for item in media if item.get("url") or item.get("thumb")],
|
||||
"comments": [item for item in (_normalize_comment(x) for x in comment_nodes) if item.get("content") or item.get("author_wxid")],
|
||||
"likes": [item for item in (_normalize_like(x) for x in like_nodes) if item.get("author_wxid")],
|
||||
"raw": node
|
||||
}
|
||||
|
||||
|
||||
def _extract_posts(payload, server):
|
||||
posts = []
|
||||
seen_ids = set()
|
||||
for node in _walk_nodes(payload):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
looks_like_post = any(key in node for key in ("Id", "id", "ContentDesc", "CommentList", "LikeList", "ObjectDesc"))
|
||||
has_timeline_xml = any(isinstance(value, str) and "<TimelineObject" in value for value in node.values())
|
||||
if not looks_like_post and not has_timeline_xml:
|
||||
continue
|
||||
post = _normalize_post(node, server)
|
||||
if not post.get("id"):
|
||||
continue
|
||||
if post["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(post["id"])
|
||||
posts.append(post)
|
||||
return posts
|
||||
|
||||
|
||||
@friend_circle_bp.route("/friend_circle")
|
||||
@login_required
|
||||
def friend_circle_page():
|
||||
return render_template("friend_circle.html")
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/contacts")
|
||||
@login_required
|
||||
def friend_circle_contacts():
|
||||
server = current_app.dashboard_server
|
||||
keyword = request.args.get("keyword", "").strip().lower()
|
||||
result = []
|
||||
for wxid, nickname in server.contact_manager.get_personal_contacts().items():
|
||||
if keyword and keyword not in wxid.lower() and keyword not in str(nickname).lower():
|
||||
continue
|
||||
result.append({
|
||||
"wxid": wxid,
|
||||
"nickname": nickname,
|
||||
"avatar": server.contact_manager.get_head_image(wxid)
|
||||
})
|
||||
if len(result) >= 50:
|
||||
break
|
||||
return jsonify({"success": True, "data": result})
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/list")
|
||||
@login_required
|
||||
def friend_circle_list():
|
||||
server = current_app.dashboard_server
|
||||
towxid = request.args.get("towxid", "").strip()
|
||||
max_id = int(request.args.get("max_id", 0))
|
||||
first_page_md5 = request.args.get("first_page_md5", "").strip()
|
||||
|
||||
try:
|
||||
if towxid:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.get_friend_circle_detail(towxid=towxid, max_id=max_id, first_page_md5=first_page_md5)
|
||||
)
|
||||
else:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.get_friend_circle_list(max_id=max_id, first_page_md5=first_page_md5)
|
||||
)
|
||||
posts = _extract_posts(payload, server)
|
||||
return jsonify({"success": True, "data": posts, "raw": payload})
|
||||
except Exception as exc:
|
||||
logger.error(f"获取朋友圈列表失败: {exc}")
|
||||
return jsonify({"success": False, "message": str(exc)}), 500
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/detail")
|
||||
@login_required
|
||||
def friend_circle_detail():
|
||||
server = current_app.dashboard_server
|
||||
object_id = request.args.get("id", "").strip()
|
||||
towxid = request.args.get("towxid", "").strip()
|
||||
|
||||
try:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.get_friend_circle_id_detail(object_id=object_id, towxid=towxid)
|
||||
)
|
||||
posts = _extract_posts(payload, server)
|
||||
detail = posts[0] if posts else None
|
||||
return jsonify({"success": True, "data": detail, "raw": payload})
|
||||
except Exception as exc:
|
||||
logger.error(f"获取朋友圈详情失败: {exc}")
|
||||
return jsonify({"success": False, "message": str(exc)}), 500
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/publish", methods=["POST"])
|
||||
@login_required
|
||||
def friend_circle_publish():
|
||||
server = current_app.dashboard_server
|
||||
data = request.get_json(silent=True) or {}
|
||||
content = (data.get("content") or "").strip()
|
||||
blacklist = (data.get("blacklist") or "").strip()
|
||||
with_user_list = (data.get("with_user_list") or "").strip()
|
||||
images = data.get("images") or []
|
||||
|
||||
if not content and not images:
|
||||
return jsonify({"success": False, "message": "内容和图片不能同时为空"}), 400
|
||||
|
||||
try:
|
||||
media_items = []
|
||||
for image in images:
|
||||
upload_data = _run_client_coro(
|
||||
server,
|
||||
server.client.upload_friend_circle_media(image)
|
||||
)
|
||||
urls = _extract_list(upload_data.get("Urls"))
|
||||
thumbs = _extract_list(upload_data.get("ThumbUrls"))
|
||||
media_items.append({
|
||||
"id": _safe_text(upload_data.get("Id")) or _safe_text(upload_data.get("MediaId")),
|
||||
"url": _safe_text(urls[0].get("Url")) if urls else "",
|
||||
"thumb": _safe_text(thumbs[0].get("Url")) if thumbs else (_safe_text(urls[0].get("Url")) if urls else ""),
|
||||
"md5": _safe_text(upload_data.get("Md5")),
|
||||
"total_size": upload_data.get("TotalSize", 0),
|
||||
"width": upload_data.get("Width", 0),
|
||||
"height": upload_data.get("Height", 0)
|
||||
})
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.publish_friend_circle(
|
||||
content=content,
|
||||
media_items=media_items,
|
||||
blacklist=blacklist,
|
||||
with_user_list=with_user_list
|
||||
)
|
||||
)
|
||||
return jsonify({"success": True, "data": payload})
|
||||
except Exception as exc:
|
||||
logger.error(f"发布朋友圈失败: {exc}")
|
||||
return jsonify({"success": False, "message": str(exc)}), 500
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/like", methods=["POST"])
|
||||
@login_required
|
||||
def friend_circle_like():
|
||||
server = current_app.dashboard_server
|
||||
data = request.get_json(silent=True) or {}
|
||||
object_id = str(data.get("id") or "").strip()
|
||||
cancel_like = bool(data.get("cancel"))
|
||||
|
||||
if not object_id:
|
||||
return jsonify({"success": False, "message": "缺少朋友圈ID"}), 400
|
||||
|
||||
try:
|
||||
if cancel_like:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.friend_circle_operation(object_id=object_id, type=5)
|
||||
)
|
||||
else:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.friend_circle_comment(object_id=object_id, type=1)
|
||||
)
|
||||
return jsonify({"success": True, "data": payload})
|
||||
except Exception as exc:
|
||||
logger.error(f"朋友圈点赞操作失败: {exc}")
|
||||
return jsonify({"success": False, "message": str(exc)}), 500
|
||||
|
||||
|
||||
@friend_circle_bp.route("/api/friend_circle/comment", methods=["POST"])
|
||||
@login_required
|
||||
def friend_circle_comment():
|
||||
server = current_app.dashboard_server
|
||||
data = request.get_json(silent=True) or {}
|
||||
object_id = str(data.get("id") or "").strip()
|
||||
content = (data.get("content") or "").strip()
|
||||
reply_comment_id = int(data.get("reply_comment_id") or 0)
|
||||
|
||||
if not object_id or not content:
|
||||
return jsonify({"success": False, "message": "评论目标和内容不能为空"}), 400
|
||||
|
||||
try:
|
||||
payload = _run_client_coro(
|
||||
server,
|
||||
server.client.friend_circle_comment(
|
||||
object_id=object_id,
|
||||
content=content,
|
||||
type=2,
|
||||
reply_comment_id=reply_comment_id
|
||||
)
|
||||
)
|
||||
return jsonify({"success": True, "data": payload})
|
||||
except Exception as exc:
|
||||
logger.error(f"朋友圈评论失败: {exc}")
|
||||
return jsonify({"success": False, "message": str(exc)}), 500
|
||||
@@ -147,6 +147,7 @@ class DashboardServer:
|
||||
from admin.dashboard.blueprints.virtual_group import virtual_group_bp
|
||||
from admin.dashboard.blueprints.file_browser import file_browser_bp
|
||||
from admin.dashboard.blueprints.message_push import message_push_bp
|
||||
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
|
||||
|
||||
# 在app.register_blueprint部分添加
|
||||
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
||||
@@ -160,6 +161,7 @@ class DashboardServer:
|
||||
app.register_blueprint(plugin_routes)
|
||||
app.register_blueprint(file_browser_bp)
|
||||
app.register_blueprint(message_push_bp)
|
||||
app.register_blueprint(friend_circle_bp)
|
||||
|
||||
self.LOG.info("所有蓝图已注册")
|
||||
|
||||
|
||||
@@ -666,6 +666,16 @@
|
||||
{ label: '错误日志', path: '/errors' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'friend-circle',
|
||||
label: '朋友圈',
|
||||
icon: 'el-icon-picture-outline-round',
|
||||
description: '集中管理朋友圈查看、发布、点赞与评论流程',
|
||||
defaultPath: '/friend_circle',
|
||||
items: [
|
||||
{ label: '朋友圈管理', path: '/friend_circle' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'groups',
|
||||
label: '群组',
|
||||
|
||||
456
admin/dashboard/templates/friend_circle.html
Normal file
456
admin/dashboard/templates/friend_circle.html
Normal file
@@ -0,0 +1,456 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}朋友圈管理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-shell friend-circle-page">
|
||||
<div class="page-hero">
|
||||
<div class="page-hero-copy">
|
||||
<div class="page-eyebrow">Friend Circle Workspace</div>
|
||||
<h1>朋友圈管理</h1>
|
||||
<p>在后台直接完成朋友圈查看、发布、点赞和评论,不再依赖聊天指令。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button @click="resetFilters">重置视图</el-button>
|
||||
<el-button type="primary" @click="loadPosts">刷新列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="friend-circle-grid">
|
||||
<div class="friend-circle-main">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>查看范围</h3>
|
||||
<p>支持首页列表和指定好友朋友圈切换查看。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :inline="true" size="small" class="filter-form">
|
||||
<el-form-item label="查看模式">
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button label="home">首页流</el-radio-button>
|
||||
<el-radio-button label="user">指定好友</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="viewMode === 'user'" label="好友">
|
||||
<el-select
|
||||
v-model="selectedTowxid"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
placeholder="输入昵称或wxid搜索"
|
||||
:remote-method="searchContacts"
|
||||
:loading="contactsLoading"
|
||||
style="width: 320px;">
|
||||
<el-option
|
||||
v-for="item in contactOptions"
|
||||
:key="item.wxid"
|
||||
:label="`${item.nickname} (${item.wxid})`"
|
||||
:value="item.wxid">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="viewMode === 'user'" label="或手动输入">
|
||||
<el-input v-model="manualTowxid" placeholder="wxid_xxx"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分页游标">
|
||||
<el-input-number v-model="maxId" :min="0" :step="1" controls-position="right"></el-input-number>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadPosts">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div v-loading="loading" class="post-list">
|
||||
<el-empty v-if="!loading && posts.length === 0" description="暂无朋友圈数据"></el-empty>
|
||||
|
||||
<el-card v-for="post in posts" :key="post.id" class="post-card" shadow="hover">
|
||||
<div class="post-header">
|
||||
<div class="post-author">
|
||||
<el-avatar :src="post.author_avatar" :size="42">{{ (post.author_name || '?').slice(0, 1) }}</el-avatar>
|
||||
<div>
|
||||
<div class="post-author-name">{% raw %}{{ post.author_name || post.author_wxid || '未知用户' }}{% endraw %}</div>
|
||||
<div class="post-meta">{% raw %}{{ post.timestamp || '时间未知' }} · ID: {{ post.id }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<el-button type="text" @click="openRawDialog(post)">原始数据</el-button>
|
||||
<el-button type="text" @click="openCommentDialog(post)">评论</el-button>
|
||||
<el-button type="text" @click="toggleLike(post, false)">点赞</el-button>
|
||||
<el-button type="text" class="danger-text" @click="toggleLike(post, true)">取消赞</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content">{% raw %}{{ post.content || '这条朋友圈没有解析出文字内容' }}{% endraw %}</div>
|
||||
|
||||
<div v-if="post.media && post.media.length" class="post-media-grid">
|
||||
<div v-for="(media, index) in post.media" :key="`${post.id}-${index}`" class="post-media-item">
|
||||
<img :src="media.thumb || media.url" :alt="post.content || '朋友圈图片'" @click="previewImage(media)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-feedback">
|
||||
<div class="feedback-block">
|
||||
<div class="feedback-title">点赞 {% raw %}{{ post.likes.length }}{% endraw %}</div>
|
||||
<div v-if="post.likes.length" class="feedback-list">
|
||||
<span v-for="(like, index) in post.likes" :key="`${post.id}-like-${index}`" class="feedback-chip">
|
||||
{% raw %}{{ like.author_name || like.author_wxid }}{% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="feedback-empty">暂无点赞</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-block">
|
||||
<div class="feedback-title">评论 {% raw %}{{ post.comments.length }}{% endraw %}</div>
|
||||
<div v-if="post.comments.length" class="comment-list">
|
||||
<div v-for="(comment, index) in post.comments" :key="`${post.id}-comment-${index}`" class="comment-item">
|
||||
<span class="comment-author">{% raw %}{{ comment.author_name || comment.author_wxid || '匿名' }}{% endraw %}</span>
|
||||
<span class="comment-content">{% raw %}{{ comment.content }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="feedback-empty">暂无评论</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="friend-circle-side">
|
||||
<el-card class="workspace-card publish-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>发布朋友圈</h3>
|
||||
<p>支持纯文本,也支持上传多张图片一起发布。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="文案">
|
||||
<el-input
|
||||
v-model="publishForm.content"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="输入朋友圈文案">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="可见人白名单">
|
||||
<el-input v-model="publishForm.with_user_list" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="不可见人黑名单">
|
||||
<el-input v-model="publishForm.blacklist" placeholder="多个wxid使用英文逗号分隔"></el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="配图">
|
||||
<div class="upload-toolbar">
|
||||
<label class="upload-trigger">
|
||||
<input type="file" accept="image/*" multiple @change="handleImageChange">
|
||||
<span>选择图片</span>
|
||||
</label>
|
||||
<el-button type="text" @click="clearImages" v-if="publishImages.length">清空</el-button>
|
||||
</div>
|
||||
<div v-if="publishImages.length" class="upload-preview-grid">
|
||||
<div v-for="(image, index) in publishImages" :key="`upload-${index}`" class="upload-preview-item">
|
||||
<img :src="image" alt="预览图">
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="publishing" @click="submitPublish">发布</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog title="发表评论" :visible.sync="commentDialogVisible" width="420px">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="朋友圈ID">
|
||||
<el-input :value="selectedPost ? selectedPost.id : ''" disabled></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="回复评论ID">
|
||||
<el-input-number v-model="commentForm.reply_comment_id" :min="0" :step="1" controls-position="right"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="评论内容">
|
||||
<el-input v-model="commentForm.content" type="textarea" :rows="4" placeholder="输入评论内容"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<span slot="footer">
|
||||
<el-button @click="commentDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="commentSubmitting" @click="submitComment">提交评论</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="原始返回" :visible.sync="rawDialogVisible" width="70%">
|
||||
<pre class="raw-json">{% raw %}{{ rawDialogContent }}{% endraw %}</pre>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :visible.sync="imagePreviewVisible" width="70%" class="image-preview-dialog">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" class="large-preview" alt="朋友圈图片预览">
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
currentView: 'friend_circle',
|
||||
showTimeRangeSelector: false,
|
||||
loading: false,
|
||||
publishing: false,
|
||||
commentSubmitting: false,
|
||||
contactsLoading: false,
|
||||
viewMode: 'home',
|
||||
selectedTowxid: '',
|
||||
manualTowxid: '',
|
||||
maxId: 0,
|
||||
posts: [],
|
||||
contactOptions: [],
|
||||
publishImages: [],
|
||||
publishForm: {
|
||||
content: '',
|
||||
blacklist: '',
|
||||
with_user_list: ''
|
||||
},
|
||||
commentDialogVisible: false,
|
||||
rawDialogVisible: false,
|
||||
imagePreviewVisible: false,
|
||||
previewImageUrl: '',
|
||||
rawDialogContent: '',
|
||||
selectedPost: null,
|
||||
commentForm: {
|
||||
content: '',
|
||||
reply_comment_id: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadPosts();
|
||||
},
|
||||
methods: {
|
||||
async loadPosts() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params = {};
|
||||
const targetWxid = this.manualTowxid.trim() || this.selectedTowxid;
|
||||
if (this.viewMode === 'user' && targetWxid) {
|
||||
params.towxid = targetWxid;
|
||||
}
|
||||
if (this.maxId) {
|
||||
params.max_id = this.maxId;
|
||||
}
|
||||
const response = await axios.get('/api/friend_circle/list', { params });
|
||||
if (response.data.success) {
|
||||
this.posts = response.data.data || [];
|
||||
} else {
|
||||
this.$message.error(response.data.message || '加载失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '加载朋友圈失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async searchContacts(keyword) {
|
||||
this.contactsLoading = true;
|
||||
try {
|
||||
const response = await axios.get('/api/friend_circle/contacts', {
|
||||
params: { keyword }
|
||||
});
|
||||
this.contactOptions = response.data.data || [];
|
||||
} catch (error) {
|
||||
this.contactOptions = [];
|
||||
} finally {
|
||||
this.contactsLoading = false;
|
||||
}
|
||||
},
|
||||
handleImageChange(event) {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (!files.length) return;
|
||||
Promise.all(files.map(file => this.fileToBase64(file))).then(images => {
|
||||
this.publishImages = this.publishImages.concat(images);
|
||||
});
|
||||
event.target.value = '';
|
||||
},
|
||||
fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
clearImages() {
|
||||
this.publishImages = [];
|
||||
},
|
||||
async submitPublish() {
|
||||
if (!this.publishForm.content.trim() && this.publishImages.length === 0) {
|
||||
this.$message.warning('请填写文案或选择图片');
|
||||
return;
|
||||
}
|
||||
this.publishing = true;
|
||||
try {
|
||||
const response = await axios.post('/api/friend_circle/publish', {
|
||||
content: this.publishForm.content,
|
||||
blacklist: this.publishForm.blacklist,
|
||||
with_user_list: this.publishForm.with_user_list,
|
||||
images: this.publishImages
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.$message.success('朋友圈发布成功');
|
||||
this.publishForm = { content: '', blacklist: '', with_user_list: '' };
|
||||
this.publishImages = [];
|
||||
this.loadPosts();
|
||||
} else {
|
||||
this.$message.error(response.data.message || '发布失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '发布朋友圈失败');
|
||||
} finally {
|
||||
this.publishing = false;
|
||||
}
|
||||
},
|
||||
openCommentDialog(post) {
|
||||
this.selectedPost = post;
|
||||
this.commentForm = { content: '', reply_comment_id: 0 };
|
||||
this.commentDialogVisible = true;
|
||||
},
|
||||
async submitComment() {
|
||||
if (!this.selectedPost || !this.commentForm.content.trim()) {
|
||||
this.$message.warning('请输入评论内容');
|
||||
return;
|
||||
}
|
||||
this.commentSubmitting = true;
|
||||
try {
|
||||
const response = await axios.post('/api/friend_circle/comment', {
|
||||
id: this.selectedPost.id,
|
||||
content: this.commentForm.content,
|
||||
reply_comment_id: this.commentForm.reply_comment_id
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.$message.success('评论成功');
|
||||
this.commentDialogVisible = false;
|
||||
this.loadPosts();
|
||||
} else {
|
||||
this.$message.error(response.data.message || '评论失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '评论失败');
|
||||
} finally {
|
||||
this.commentSubmitting = false;
|
||||
}
|
||||
},
|
||||
async toggleLike(post, cancel) {
|
||||
try {
|
||||
const response = await axios.post('/api/friend_circle/like', {
|
||||
id: post.id,
|
||||
cancel: cancel
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.$message.success(cancel ? '已取消点赞' : '点赞成功');
|
||||
this.loadPosts();
|
||||
} else {
|
||||
this.$message.error(response.data.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '点赞操作失败');
|
||||
}
|
||||
},
|
||||
openRawDialog(post) {
|
||||
this.rawDialogContent = JSON.stringify(post.raw || post, null, 2);
|
||||
this.rawDialogVisible = true;
|
||||
},
|
||||
previewImage(media) {
|
||||
this.previewImageUrl = media.url || media.thumb || '';
|
||||
this.imagePreviewVisible = true;
|
||||
},
|
||||
resetFilters() {
|
||||
this.viewMode = 'home';
|
||||
this.selectedTowxid = '';
|
||||
this.manualTowxid = '';
|
||||
this.maxId = 0;
|
||||
this.loadPosts();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-shell { display: flex; flex-direction: column; gap: 16px; }
|
||||
.page-hero {
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 24px 26px; border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(34,197,94,0.10), rgba(56,189,248,0.08), rgba(255,255,255,0.92));
|
||||
border: 1px solid rgba(148, 163, 184, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #16a34a; font-weight: 700; margin-bottom: 8px; }
|
||||
.page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; }
|
||||
.page-hero-copy p { color: #64748b; font-size: 14px; }
|
||||
.page-hero-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.friend-circle-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr); gap: 16px; align-items: start; }
|
||||
.friend-circle-main, .friend-circle-side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||||
.workspace-header p { font-size: 13px; color: #64748b; }
|
||||
.filter-form { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; }
|
||||
.post-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.post-card { overflow: hidden; }
|
||||
.post-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.post-author { display: flex; align-items: center; gap: 12px; }
|
||||
.post-author-name { font-size: 16px; font-weight: 700; color: #0f172a; }
|
||||
.post-meta { font-size: 12px; color: #64748b; margin-top: 3px; }
|
||||
.post-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.post-content { color: #0f172a; line-height: 1.8; margin-bottom: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.post-media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
||||
.post-media-item img, .upload-preview-item img {
|
||||
width: 100%; height: 108px; object-fit: cover; border-radius: 16px; cursor: pointer;
|
||||
border: 1px solid rgba(148,163,184,0.14); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.post-feedback { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.feedback-block {
|
||||
padding: 14px; border-radius: 18px; background: rgba(248,250,252,0.88);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.feedback-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
|
||||
.feedback-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.feedback-chip {
|
||||
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; font-size: 12px;
|
||||
color: #334155; background: rgba(255,255,255,0.95); border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.feedback-empty { color: #94a3b8; font-size: 12px; }
|
||||
.comment-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.comment-item { font-size: 13px; color: #334155; line-height: 1.6; }
|
||||
.comment-author { font-weight: 700; color: #0f172a; margin-right: 8px; }
|
||||
.danger-text { color: #ef4444 !important; }
|
||||
.upload-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.upload-trigger {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 14px;
|
||||
border-radius: 12px; background: rgba(255,255,255,0.92); border: 1px solid rgba(148,163,184,0.2);
|
||||
color: #0f172a; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.upload-trigger input { display: none; }
|
||||
.upload-preview-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 12px; }
|
||||
.raw-json {
|
||||
margin: 0; max-height: 520px; overflow: auto; white-space: pre-wrap; word-break: break-all;
|
||||
padding: 14px; border-radius: 16px; background: rgba(248,250,252,0.9); color: #334155;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.large-preview { width: 100%; border-radius: 18px; }
|
||||
@media (max-width: 1100px) {
|
||||
.friend-circle-grid { grid-template-columns: 1fr; }
|
||||
.post-feedback { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,5 @@
|
||||
from wechat_ipad import UserLoggedOut
|
||||
from wechat_ipad.client.friend_circle import FriendCircleMixin
|
||||
from wechat_ipad.client.firends import FriendMixin
|
||||
from wechat_ipad.client.group import ChatroomMixin
|
||||
from wechat_ipad.client.login import LoginMixin
|
||||
@@ -7,7 +8,7 @@ from wechat_ipad.client.tools import ToolMixin
|
||||
from wechat_ipad.client.user import UserMixin
|
||||
|
||||
|
||||
class WechatAPIClient(LoginMixin, MessageMixin, FriendMixin, ChatroomMixin, UserMixin,
|
||||
class WechatAPIClient(LoginMixin, MessageMixin, FriendCircleMixin, FriendMixin, ChatroomMixin, UserMixin,
|
||||
ToolMixin):
|
||||
|
||||
# 这里都是需要结合多个功能的方法
|
||||
|
||||
145
wechat_ipad/client/friend_circle.py
Normal file
145
wechat_ipad/client/friend_circle.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import base64
|
||||
import os
|
||||
from typing import Union
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
|
||||
from wechat_ipad import UserLoggedOut
|
||||
from wechat_ipad.client.base import WechatAPIClientBase
|
||||
from wechat_ipad.models.friend_circle_info import build_friend_circle_xml
|
||||
|
||||
|
||||
class FriendCircleMixin(WechatAPIClientBase):
|
||||
async def get_friend_circle_list(self, max_id: int = 0, first_page_md5: str = "") -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Maxid": max_id, "Fristpagemd5": first_page_md5}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetList", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def get_friend_circle_detail(self, towxid: str, max_id: int = 0, first_page_md5: str = "") -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {
|
||||
"Wxid": self.wxid,
|
||||
"Towxid": towxid,
|
||||
"Maxid": max_id,
|
||||
"Fristpagemd5": first_page_md5
|
||||
}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/GetDetail", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def get_friend_circle_id_detail(self, object_id: Union[str, int], towxid: str = "") -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Towxid": towxid, "Id": int(object_id)}
|
||||
response = await session.post(
|
||||
f"http://{self.ip}:{self.port}/api/FriendCircle/GetIdDetail",
|
||||
json=json_param
|
||||
)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def publish_friend_circle(self, content: str, media_items: list[dict] | None = None,
|
||||
blacklist: str = "", with_user_list: str = "") -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
xml_content = build_friend_circle_xml(self.wxid, content, media_items=media_items)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {
|
||||
"Wxid": self.wxid,
|
||||
"Content": xml_content,
|
||||
"BlackList": blacklist,
|
||||
"WithUserList": with_user_list
|
||||
}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Messages", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def friend_circle_comment(self, object_id: str, content: str = "", type: int = 2,
|
||||
reply_comment_id: int = 0) -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {
|
||||
"Wxid": self.wxid,
|
||||
"Id": str(object_id),
|
||||
"Type": int(type),
|
||||
"Content": content,
|
||||
"ReplyCommnetId": int(reply_comment_id)
|
||||
}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Comment", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def friend_circle_operation(self, object_id: str, type: int, comment_id: int = 0) -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {
|
||||
"Wxid": self.wxid,
|
||||
"Id": str(object_id),
|
||||
"Type": int(type),
|
||||
"CommnetId": int(comment_id)
|
||||
}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Operation", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def sync_friend_circle(self, sync_key: str = "") -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Synckey": sync_key}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/MmSnsSync", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
|
||||
async def upload_friend_circle_media(self, media: Union[str, bytes, os.PathLike]) -> dict:
|
||||
if not self.wxid:
|
||||
raise UserLoggedOut("请先登录")
|
||||
|
||||
if isinstance(media, str):
|
||||
media_base64 = media.split(",", 1)[1] if "," in media else media
|
||||
elif isinstance(media, bytes):
|
||||
media_base64 = base64.b64encode(media).decode()
|
||||
elif isinstance(media, os.PathLike):
|
||||
async with aiofiles.open(media, "rb") as f:
|
||||
media_base64 = base64.b64encode(await f.read()).decode()
|
||||
else:
|
||||
raise ValueError("media should be str, bytes, or path")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
json_param = {"Wxid": self.wxid, "Base64": media_base64}
|
||||
response = await session.post(f"http://{self.ip}:{self.port}/api/FriendCircle/Upload", json=json_param)
|
||||
json_resp = await response.json()
|
||||
if json_resp.get("Success"):
|
||||
return json_resp.get("Data", {})
|
||||
self.error_handler(json_resp)
|
||||
@@ -1,25 +1,65 @@
|
||||
FRIEND_CIRCLE_INFO = """
|
||||
import random
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def _safe_float(value) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def generate_timeline_id() -> str:
|
||||
return f"{int(time.time() * 1000)}{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def build_media_xml(media_items: Iterable[dict]) -> str:
|
||||
parts = []
|
||||
for index, item in enumerate(media_items):
|
||||
url = item.get("url", "")
|
||||
thumb = item.get("thumb", url)
|
||||
media_id = item.get("id") or f"{int(time.time() * 1000)}{index}"
|
||||
md5 = item.get("md5", "")
|
||||
total_size = item.get("total_size", 0)
|
||||
width = item.get("width", 0)
|
||||
height = item.get("height", 0)
|
||||
parts.append(
|
||||
f"""
|
||||
<media>
|
||||
<id><![CDATA[{media_id}]]></id>
|
||||
<type><![CDATA[2]]></type>
|
||||
<title></title>
|
||||
<description></description>
|
||||
<private><![CDATA[0]]></private>
|
||||
<url type="1" md5="{md5}"><![CDATA[{url}]]></url>
|
||||
<thumb type="1"><![CDATA[{thumb}]]></thumb>
|
||||
<videoDuration><![CDATA[0.0]]></videoDuration>
|
||||
<size totalSize="{_safe_float(total_size):.1f}" width="{_safe_float(width):.1f}" height="{_safe_float(height):.1f}"></size>
|
||||
</media>
|
||||
""".strip()
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_friend_circle_xml(wxid: str, content: str, media_items: list[dict] | None = None,
|
||||
timeline_id: str | None = None, create_time: int | None = None) -> str:
|
||||
media_items = media_items or []
|
||||
timeline_id = timeline_id or generate_timeline_id()
|
||||
create_time = create_time or int(time.time())
|
||||
content_style = 1 if media_items else 2
|
||||
media_xml = build_media_xml(media_items)
|
||||
|
||||
return f"""
|
||||
<TimelineObject>
|
||||
<id>
|
||||
<![CDATA[{id}]]>
|
||||
</id>
|
||||
<username>
|
||||
<![CDATA[{wxid}]]>
|
||||
</username>
|
||||
<createTime>
|
||||
<![CDATA[{time}]]>
|
||||
</createTime>
|
||||
<id><![CDATA[{timeline_id}]]></id>
|
||||
<username><![CDATA[{wxid}]]></username>
|
||||
<createTime><![CDATA[{create_time}]]></createTime>
|
||||
<contentDescShowType>0</contentDescShowType>
|
||||
<contentDescScene>0</contentDescScene>
|
||||
<private>
|
||||
<![CDATA[0]]>
|
||||
</private>
|
||||
<contentDesc>
|
||||
<![CDATA[{content}]]>
|
||||
</contentDesc>
|
||||
<contentattr>
|
||||
<![CDATA[0]]>
|
||||
</contentattr>
|
||||
<private><![CDATA[0]]></private>
|
||||
<contentDesc><![CDATA[{content}]]></contentDesc>
|
||||
<contentattr><![CDATA[0]]></contentattr>
|
||||
<sourceUserName></sourceUserName>
|
||||
<publicUserName></publicUserName>
|
||||
<sourceNickName></sourceNickName>
|
||||
@@ -27,58 +67,24 @@ FRIEND_CIRCLE_INFO = """
|
||||
<weappInfo>
|
||||
<appUserName></appUserName>
|
||||
<pagePath></pagePath>
|
||||
<version>
|
||||
<![CDATA[0]]>
|
||||
</version>
|
||||
<version><![CDATA[0]]></version>
|
||||
<isHidden>0</isHidden>
|
||||
<debugMode>
|
||||
<![CDATA[0]]>
|
||||
</debugMode>
|
||||
<debugMode><![CDATA[0]]></debugMode>
|
||||
<shareActionId></shareActionId>
|
||||
<isGame>
|
||||
<![CDATA[0]]>
|
||||
</isGame>
|
||||
<isGame><![CDATA[0]]></isGame>
|
||||
<messageExtraData></messageExtraData>
|
||||
<subType>
|
||||
<![CDATA[0]]>
|
||||
</subType>
|
||||
<subType><![CDATA[0]]></subType>
|
||||
<preloadResources></preloadResources>
|
||||
</weappInfo>
|
||||
<canvasInfoXml></canvasInfoXml>
|
||||
<ContentObject>
|
||||
<contentStyle>
|
||||
<![CDATA[1]]>
|
||||
</contentStyle>
|
||||
<contentSubStyle>
|
||||
<![CDATA[0]]>
|
||||
</contentSubStyle>
|
||||
<contentStyle><![CDATA[{content_style}]]></contentStyle>
|
||||
<contentSubStyle><![CDATA[0]]></contentSubStyle>
|
||||
<title></title>
|
||||
<description></description>
|
||||
<contentUrl></contentUrl>
|
||||
<mediaList>
|
||||
<media>
|
||||
<id>
|
||||
<![CDATA[14672447414385119864]]>
|
||||
</id>
|
||||
<type>
|
||||
<![CDATA[2]]>
|
||||
</type>
|
||||
<title></title>
|
||||
<description></description>
|
||||
<private>
|
||||
<![CDATA[0]]>
|
||||
</private>
|
||||
<url type=\"1\" md5=\"c661215f338618b3282a6ea5174a0fb5\">
|
||||
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/0]]>
|
||||
</url>
|
||||
<thumb type=\"1\">
|
||||
<![CDATA[http://szmmsns.qpic.cn/mmsns/AcIhsXSWkDeNF6K5icia87OmccBSE2sDH5gib5UWibjTCU1snn0WANibQoy4Obad1ibmQO6aNQXOTP4Fc/150]]>
|
||||
</thumb>
|
||||
<videoDuration>
|
||||
<![CDATA[0.0]]>
|
||||
</videoDuration>
|
||||
<size totalSize=\"47880.0\" width=\"1080.0\" height=\"1080.0\"></size>
|
||||
</media>
|
||||
{media_xml}
|
||||
</mediaList>
|
||||
</ContentObject>
|
||||
<actionInfo>
|
||||
@@ -91,11 +97,11 @@ FRIEND_CIRCLE_INFO = """
|
||||
<appInfo>
|
||||
<id></id>
|
||||
</appInfo>
|
||||
<location poiClassifyId=\"\" poiName=\"\" poiAddress=\"\" poiClassifyType=\"0\" city=\"\"></location>
|
||||
<location poiClassifyId="" poiName="" poiAddress="" poiClassifyType="0" city=""></location>
|
||||
<streamvideo>
|
||||
<streamvideourl></streamvideourl>
|
||||
<streamvideothumburl></streamvideothumburl>
|
||||
<streamvideoweburl></streamvideoweburl>
|
||||
</streamvideo>
|
||||
</TimelineObject>
|
||||
"""
|
||||
""".strip()
|
||||
|
||||
Reference in New Issue
Block a user