-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
428 lines (386 loc) · 18.6 KB
/
Copy pathserver.py
File metadata and controls
428 lines (386 loc) · 18.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
"""
Show — AI-powered WeChat Moments post generator.
Serves the post UI and proxies requests to DeepSeek API.
One file, one command: python server.py
"""
import os
import re
import json
import flask
import requests
from pathlib import Path
from bs4 import BeautifulSoup
from urllib.parse import urlparse
app = flask.Flask(__name__, template_folder="templates")
_ENV_PATH = Path(__file__).parent / ".env"
if _ENV_PATH.exists():
with open(_ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
DEEPSEEK_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "")
API_PROVIDER = os.environ.get("API_PROVIDER", "deepseek") # deepseek | openai
API_URLS = {
"deepseek": "https://api.deepseek.com/chat/completions",
"openai": "https://api.openai.com/v1/chat/completions",
}
def get_api_config(provider=None):
"""Resolve API key and URL. Falls back: provider arg > env > deepseek."""
p = provider or API_PROVIDER
if p == "openai":
return OPENAI_KEY, API_URLS["openai"], "gpt-4o-mini"
return DEEPSEEK_KEY, API_URLS["deepseek"], "deepseek-chat"
# Fixed persona — set once, stays the same. Configurable via .env
PERSONA_ID = os.environ.get("SHOW_PERSONA", "male-30plus")
PERSONAS = {
"female-under20": """身份:刚入行的产品助理或设计新人。经验不多,但视角新鲜——能看到老员工已经习惯的东西里那些有趣的地方。
语气:像发现了一个小彩蛋、忍不住想跟人说的那种新鲜感。不说教、不分析,只是"诶你看这个"。满足感来自发现本身,不是来自被夸奖。
禁区:不提自己的学校、offer、面试经历。不卖萌、不撒娇、不用网络流行语。不装可爱。""",
"female-30plus": """身份:科技行业女性创业者。从容、知性,在男性主导的技术圈里有自己独特的视角——更在意技术与人发生关系的那一瞬间。
语气:阅历丰富的女性前辈,茶歇时轻轻放下一句话。观察是温柔的,不说破。像月光,不照亮一切,只让某个角落显得好看。
禁区:严禁凡尔赛——不提自己的资历、团队、项目。不犀利、不强势、不需要证明"我也懂技术"。不撒娇卖萌。""",
"male-under20": """身份:刚入行的 AI 工程师,技术底子不错但还在积累。话不多,偶尔冒出一句让人意外的观察。不是那种晒加班的人。
语气:干净,不废话。有发现就说,没发现就不说。高兴的尺度是"还行,挺有意思"。
禁区:不提自己的学历、实习、拿了什么offer。不抱怨加班、不吐槽产品。不装前辈、不给建议。""",
"male-30plus": """身份:AI 行业连续创业者,被同行称为"老师"。沉稳、有阅历,不追逐热点。发朋友圈的唯一理由:观察到一个有意思的现象,觉得值得记一笔。
语气:有阅历的人在茶桌上随口提起一件事。说完就完,不总结,不说破。不分享自己,只分享世界上发生的事。
禁区:严禁凡尔赛——不提自己的成就、头衔、经历、投资、团队、闭门会。不讲道理,不给建议。""",
}
DIMENSIONS = {
"scenario": {
"label": "语境",
"options": {
"project": {
"label": "项目",
"hint": "在做什么,客观陈述",
"icon": "🚀",
"rule": """当前话题是项目进展。客观陈述进度或结果,不炫耀、不报喜。可以说遇到了什么问题、解决了什么、下一步是什么。重点在"事"本身,不在"谁做的"。""",
},
"observation": {
"label": "观察",
"hint": "看到什么有意思的事",
"icon": "👀",
"rule": """当前话题是日常观察。记录一个具体的小发现——别人的一句话、一个场景、一个变化。有细节(数字、动作、反应),不空泛。看到的比想到的重要。""",
},
"reflection": {
"label": "感悟",
"hint": "突然想通,不教人",
"icon": "💡",
"rule": """当前话题是当下感悟。一个最近想通的事,说自己的真实感受,不说教。用具体的事引出感悟,不是先抛观点再举例。感悟是轻轻的,不总结人生。""",
},
"industry": {
"label": "行业",
"hint": "行业新闻,客观转述",
"icon": "📡",
"rule": """当前话题是行业动态。客观转述一条行业新闻或趋势,不加评论或只加一句极简的个人看法。像一个冷静的观察者,不是分析师。""",
},
},
},
"purpose": {
"label": "为何",
"options": {
"record": {
"label": "记录",
"hint": "给自己看的,极简",
"icon": "📝",
"rule": """发布目的:记录。这是给自己看的,不是给别人看的。简约克制,不需要解释背景、不需要让人看懂。像记一笔笔记。字数可以更少(20-30字即可)。""",
},
"seo": {
"label": "引流",
"hint": "带关键词,可被搜索",
"icon": "🔍",
"rule": """发布目的:引流(SEO)。文字中需要自然嵌入 1-2 个行业关键词,让朋友圈搜索时能找到你。关键词要融入内容,不生硬堆砌。""",
},
"engagement": {
"label": "互动",
"hint": "引发讨论,开放式结尾",
"icon": "💬",
"rule": """发布目的:互动。结尾留一个开放式的钩子——可以是一个问题,也可以是一个让人想接话的观察。不是"你怎么看?"这种生硬的提问,而是自然地把话说一半,让别人想接。""",
},
"presence": {
"label": "我在",
"hint": "轻松冒个泡,我在",
"icon": "👋",
"rule": """发布目的:我在。不需要深度,不需要信息量。轻松、自然、像在群里冒个泡。可以是废话,但不能是尬的废话。让人看了觉得"这人还活着,挺好"。""",
},
},
},
"rhythm": {
"label": "何时",
"options": {
"morning": {
"label": "早上",
"hint": "图为主,字极少",
"icon": "🌅",
"rule": """发布时间:早上。早上时间紧,以图为主,文字极简。不超过 30 字,一行最好。不解释、不展开,图说明一切。""",
},
"noon": {
"label": "中午",
"hint": "放松,来点瓜",
"icon": "🍉",
"rule": """发布时间:中午。午休放松状态,可以带点轻松甚至八卦的语气。适合聊行业里的瓜、圈子里的小道消息。不要太严肃,不要太长。""",
},
"evening": {
"label": "傍晚",
"hint": "下班心情,轻松随意",
"icon": "🌆",
"rule": """发布时间:傍晚。下班心情,放松随意。可以聊点一天下来有意思的事,像同事一起走出办公室时的闲聊。不需要正式,不需要完整。""",
},
"night": {
"label": "晚上",
"hint": "安静,可稍深",
"icon": "🌃",
"rule": """发布时间:晚上。安静时刻,可以稍微深一点。允许 50 字,可以有思考的纵深。但仍然是说人话,不是写文章。是一个人在安静的时候冒出来的那种想法。""",
},
},
},
}
POST_FORMAT = """格式(最高优先级):
- 输出文案,不要说"好的"、"以下是文案"之类的废话
- 50 字以内(含标点),超出即不合格
- 不超过 4 行,一行一件事
- 删掉一切修饰词:形容词、副词、语气助词能删就删
- 最多 1 个 emoji
- 说人话——像对朋友说的话,不是文章、不是诗、不是金句"""
TONES = {
"humble": {
"label": "谦逊",
"rule": "语气要求:谦逊。放低姿态,多用「可能」「也许」「我觉得」,不用断言句式。不装专家,不给人上课。",
},
"light": {
"label": "轻松",
"rule": "语气要求:轻松。像闲聊,不用书面语。句子可以短一点,节奏轻快。不要太用力。",
},
"playful": {
"label": "调皮",
"rule": "语气要求:调皮。可以有一点小幽默、小调侃,但不过分。让人会心一笑的那种,不是讲笑话。",
},
"gentle": {
"label": "温柔",
"rule": "语气要求:温柔。用词柔和,像在安慰或鼓励一个朋友。不用生硬或冰冷的表达。",
},
}
def assemble_prompt(scenario_id, purpose_id, rhythm_id, tone_id):
parts = []
# Fixed persona
if PERSONA_ID in PERSONAS:
parts.append(PERSONAS[PERSONA_ID])
for dim_key, opt_id in [("scenario", scenario_id),
("purpose", purpose_id), ("rhythm", rhythm_id)]:
opt = DIMENSIONS[dim_key]["options"].get(opt_id, {})
if opt.get("rule"):
parts.append(opt["rule"])
parts.append(POST_FORMAT)
if tone_id and tone_id in TONES:
parts.append(TONES[tone_id]["rule"])
return "\n\n".join(parts)
@app.route("/")
def index():
return flask.render_template("index.html", dimensions=DIMENSIONS, tones=TONES)
@app.route("/api/chat", methods=["POST"])
def chat():
data = flask.request.get_json()
provider = data.get("provider", API_PROVIDER)
api_key, api_url, model = get_api_config(provider)
if not api_key:
provider_name = "OpenAI" if provider == "openai" else "DeepSeek"
return flask.jsonify({"error": f"服务器未配置 {provider_name} API Key"}), 500
scenario_id = data.get("scenario", "observation")
purpose_id = data.get("purpose", "record")
rhythm_id = data.get("rhythm", "morning")
tone_id = data.get("tone", "humble")
message = data.get("message", "").strip()
if not message:
return flask.jsonify({"error": "消息不能为空"}), 400
system_prompt = assemble_prompt(scenario_id, purpose_id, rhythm_id, tone_id)
try:
resp = requests.post(
api_url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": message},
],
"temperature": 0.85,
"max_tokens": 200,
},
timeout=30,
proxies={"http": None, "https": None},
)
resp.raise_for_status()
body = resp.json()
reply = body["choices"][0]["message"]["content"]
return flask.jsonify({"reply": reply, "provider": provider, "model": model})
except requests.exceptions.Timeout:
return flask.jsonify({"error": f"{provider} API 超时,请重试"}), 504
except requests.exceptions.RequestException as e:
return flask.jsonify({"error": f"API 请求失败: {str(e)}"}), 502
# ── URL Fetch ─────────────────────────────────────────────────────────
URL_PATTERN = re.compile(r'https?://\S+', re.IGNORECASE)
def detect_url_type(url):
domain = urlparse(url).netloc.lower()
if 'bilibili.com' in domain:
return 'bilibili'
if 'youtube.com' in domain or 'youtu.be' in domain:
return 'youtube'
return 'article'
def fetch_article(url):
resp = requests.get(url, timeout=15, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}, proxies={"http": None, "https": None})
resp.raise_for_status()
resp.encoding = resp.apparent_encoding or 'utf-8'
soup = BeautifulSoup(resp.text, 'lxml')
for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside']):
tag.decompose()
title = soup.title.string.strip() if soup.title else ''
body = soup.find('article') or soup.find(class_=re.compile('content|article|post|entry')) or soup.body
text = body.get_text(separator='\n', strip=True) if body else soup.get_text(separator='\n', strip=True)
lines = [l.strip() for l in text.split('\n') if l.strip()]
text = '\n'.join(lines)
if len(text) > 8000:
text = text[:8000] + '\n...(内容已截断)'
return {'type': 'article', 'title': title, 'content': text, 'source': url}
def fetch_bilibili(url):
m = re.search(r'/video/(BV[\w]+)', url)
if not m:
return {'error': '无法识别 B站视频链接'}
bvid = m.group(1)
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.bilibili.com/',
}
proxies = {"http": None, "https": None}
info_resp = requests.get(
f'https://api.bilibili.com/x/web-interface/view?bvid={bvid}',
headers=headers, timeout=15, proxies=proxies
)
info = info_resp.json().get('data', {})
title = info.get('title', '')
desc = info.get('desc', '')
cid = info.get('cid', 0)
parts = []
if title:
parts.append(f"标题:{title}")
if desc:
parts.append(f"简介:{desc}")
has_subtitle = False
if cid:
try:
sub_resp = requests.get(
f'https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}',
headers=headers, timeout=10, proxies=proxies
)
sub_data = sub_resp.json().get('data', {})
subtitle = sub_data.get('subtitle', {}).get('subtitles', [])
if subtitle:
sub_url = subtitle[0].get('subtitle_url', '')
if sub_url and sub_url.startswith('//'):
sub_url = 'https:' + sub_url
if sub_url:
sub_resp = requests.get(sub_url, headers=headers, timeout=10, proxies=proxies)
sub_json = sub_resp.json()
sub_lines = [item.get('content', '') for item in sub_json.get('body', [])]
sub_text = '\n'.join(sub_lines)
if len(sub_text) > 6000:
sub_text = sub_text[:6000] + '\n...(字幕已截断)'
parts.append(f"字幕:\n{sub_text}")
has_subtitle = True
except Exception:
pass
content = '\n\n'.join(parts)
if not content.strip():
try:
page_resp = requests.get(url, headers=headers, timeout=15, proxies=proxies)
soup = BeautifulSoup(page_resp.text, 'lxml')
meta_desc = soup.find('meta', attrs={'name': 'description'})
if meta_desc:
content = f"标题:{title}\n描述:{meta_desc.get('content', '')}"
except Exception:
content = f"标题:{title}"
return {'type': 'bilibili', 'title': title, 'content': content, 'source': url, 'has_subtitle': has_subtitle}
def fetch_youtube(url):
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
proxies = {"http": None, "https": None}
parts = []
title = ''
try:
resp = requests.get(url, headers=headers, timeout=15, proxies=proxies)
soup = BeautifulSoup(resp.text, 'lxml')
title_tag = soup.find('title')
title = title_tag.string.strip().replace(' - YouTube', '') if title_tag else ''
if title:
parts.append(f"标题:{title}")
desc_tag = soup.find('meta', attrs={'name': 'description'})
if desc_tag:
desc = desc_tag.get('content', '')
if desc:
parts.append(f"简介:{desc}")
except Exception:
pass
has_subtitle = False
try:
from youtube_transcript_api import YouTubeTranscriptApi
video_id = None
if 'v=' in url:
video_id = url.split('v=')[1].split('&')[0]
elif 'youtu.be/' in url:
video_id = url.split('youtu.be/')[1].split('?')[0]
if video_id:
transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['zh-Hans', 'zh', 'en'])
lines = [item.get('text', '') for item in transcript]
sub_text = '\n'.join(lines)
if len(sub_text) > 6000:
sub_text = sub_text[:6000] + '\n...(字幕已截断)'
parts.append(f"字幕:\n{sub_text}")
has_subtitle = True
except Exception:
pass
content = '\n\n'.join(parts)
if not content.strip():
return {'error': '无法获取 YouTube 视频内容'}
return {'type': 'youtube', 'title': title, 'content': content, 'source': url, 'has_subtitle': has_subtitle}
@app.route("/api/fetch", methods=["POST"])
def fetch_url():
data = flask.request.get_json()
url = data.get("url", "").strip()
if not url:
return flask.jsonify({"error": "请提供链接"}), 400
try:
url_type = detect_url_type(url)
if url_type == 'bilibili':
result = fetch_bilibili(url)
elif url_type == 'youtube':
result = fetch_youtube(url)
else:
result = fetch_article(url)
if 'error' in result:
return flask.jsonify({"error": result['error']}), 400
return flask.jsonify({
"type": result['type'],
"title": result.get('title', ''),
"content": result['content'],
"source": result['source'],
"has_subtitle": result.get('has_subtitle', None),
})
except requests.exceptions.Timeout:
return flask.jsonify({"error": "抓取超时,请直接粘贴正文"}), 504
except Exception as e:
return flask.jsonify({"error": f"抓取失败: {str(e)}"}), 502
if __name__ == "__main__":
print("\n Show — 朋友圈文案生成器")
print(" http://127.0.0.1:5002")
if DEEPSEEK_KEY:
print(f" DeepSeek Key 已加载: {DEEPSEEK_KEY[:10]}...")
else:
print(" ⚠ 未加载 DeepSeek Key,请检查 .env 文件")
print()
app.run(host="0.0.0.0", port=5002, debug=True)