503 lines
18 KiB
Python
503 lines
18 KiB
Python
import asyncio
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
|
|
import requests
|
|
from flask import Blueprint, Response, current_app, jsonify, render_template, request, stream_with_context
|
|
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 _proxy_remote_media(target_url: str) -> Response:
|
|
if not target_url:
|
|
return Response("missing url", status=400)
|
|
|
|
headers = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/123.0.0.0 Safari/537.36"
|
|
),
|
|
"Referer": "http://weixin.qq.com/"
|
|
}
|
|
range_header = request.headers.get("Range")
|
|
if range_header:
|
|
headers["Range"] = range_header
|
|
|
|
upstream = requests.get(target_url, headers=headers, stream=True, timeout=30)
|
|
|
|
response_headers = {}
|
|
for key in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Cache-Control", "ETag", "Last-Modified"):
|
|
value = upstream.headers.get(key)
|
|
if value:
|
|
response_headers[key] = value
|
|
|
|
if "Accept-Ranges" not in response_headers:
|
|
response_headers["Accept-Ranges"] = "bytes"
|
|
|
|
return Response(
|
|
stream_with_context(upstream.iter_content(chunk_size=64 * 1024)),
|
|
status=upstream.status_code,
|
|
headers=response_headers,
|
|
direct_passthrough=True
|
|
)
|
|
|
|
|
|
def _safe_text(value, default=""):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, dict):
|
|
if "buffer" in value:
|
|
return _safe_text(value.get("buffer"), default)
|
|
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 _find_timeline_xml(value):
|
|
if isinstance(value, str):
|
|
return value if "<TimelineObject" in value else ""
|
|
if isinstance(value, dict):
|
|
for key in ("buffer", "string", "text", "Content", "Xml", "ObjectDesc", "TimelineObject"):
|
|
if key in value:
|
|
result = _find_timeline_xml(value.get(key))
|
|
if result:
|
|
return result
|
|
for item in value.values():
|
|
result = _find_timeline_xml(item)
|
|
if result:
|
|
return result
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
result = _find_timeline_xml(item)
|
|
if result:
|
|
return result
|
|
return ""
|
|
|
|
|
|
def _parse_media_from_xml(root):
|
|
media_items = []
|
|
for media in root.findall(".//mediaList/media"):
|
|
media_type = _safe_text(media.findtext("type"))
|
|
size_node = media.find("size")
|
|
media_items.append({
|
|
"url": _safe_text(media.findtext("url")),
|
|
"thumb": _safe_text(media.findtext("thumb")),
|
|
"hd": _safe_text(media.findtext("hd")),
|
|
"id": _safe_text(media.findtext("id")),
|
|
"type": media_type,
|
|
"is_video": media_type == "6",
|
|
"video_duration": _safe_text(media.findtext("videoDuration")),
|
|
"width": _safe_text(size_node.get("width")) if size_node is not None else "",
|
|
"height": _safe_text(size_node.get("height")) if size_node is not None else "",
|
|
"description": _safe_text(media.findtext("description"))
|
|
})
|
|
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")
|
|
|
|
location_node = root.find("location")
|
|
location = {}
|
|
if location_node is not None:
|
|
location = {
|
|
"poi_name": _safe_text(location_node.get("poiName")),
|
|
"poi_address": _safe_text(location_node.get("poiAddress")),
|
|
"city": _safe_text(location_node.get("city")),
|
|
"latitude": _safe_text(location_node.get("latitude")),
|
|
"longitude": _safe_text(location_node.get("longitude"))
|
|
}
|
|
|
|
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),
|
|
"location": location
|
|
}
|
|
|
|
|
|
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("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("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"))
|
|
),
|
|
"create_time": _safe_text(comment.get("CreateTime")) or _safe_text(comment.get("createTime"))
|
|
}
|
|
|
|
|
|
def _normalize_like(like):
|
|
return {
|
|
"author_wxid": (
|
|
_safe_text(like.get("UserName"))
|
|
or _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("nickname"))
|
|
or _safe_text(like.get("DisplayName"))
|
|
),
|
|
"create_time": _safe_text(like.get("CreateTime")) or _safe_text(like.get("createTime"))
|
|
}
|
|
|
|
|
|
def _normalize_post(node, server):
|
|
xml_payload = ""
|
|
for key in ("Content", "Xml", "ObjectDesc", "TimelineObject"):
|
|
xml_payload = _find_timeline_xml(node.get(key))
|
|
if xml_payload:
|
|
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 _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")),
|
|
"hd": _safe_text(item.get("Hd")) or _safe_text(item.get("hd")),
|
|
"thumb": _safe_text(item.get("ThumbUrl")) or _safe_text(item.get("thumb")),
|
|
"id": _safe_text(item.get("Id")) or _safe_text(item.get("id")),
|
|
"type": _safe_text(item.get("Type")) or _safe_text(item.get("type")),
|
|
"is_video": (_safe_text(item.get("Type")) or _safe_text(item.get("type"))) == "6",
|
|
"video_duration": _safe_text(item.get("VideoDuration")) or _safe_text(item.get("videoDuration")),
|
|
"width": _safe_text(item.get("Width")) or _safe_text(item.get("width")),
|
|
"height": _safe_text(item.get("Height")) or _safe_text(item.get("height")),
|
|
"description": _safe_text(item.get("Description")) or _safe_text(item.get("description"))
|
|
})
|
|
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 ("CommentUserList", "commentUserList"):
|
|
comment_nodes.extend(_extract_list(node.get(key)))
|
|
for key in ("LikeList", "Likes", "likeList", "likes"):
|
|
like_nodes.extend(_extract_list(node.get(key)))
|
|
for key in ("LikeUserList", "likeUserList"):
|
|
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,
|
|
"like_flag": _safe_text(node.get("LikeFlag")),
|
|
"is_liked": _safe_text(node.get("LikeFlag")) in ("1", "true", "True"),
|
|
"like_count": _safe_text(node.get("LikeCount")),
|
|
"location": xml_data.get("location", {}),
|
|
"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 = bool(_find_timeline_xml(node))
|
|
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
|
|
|
|
|
|
@friend_circle_bp.route("/api/friend_circle/media_proxy")
|
|
@login_required
|
|
def friend_circle_media_proxy():
|
|
target_url = request.args.get("url", "").strip()
|
|
try:
|
|
return _proxy_remote_media(target_url)
|
|
except Exception as exc:
|
|
logger.error(f"朋友圈媒体代理失败: {exc}")
|
|
return Response(f"proxy failed: {exc}", status=502)
|