-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
5859 lines (5478 loc) · 351 KB
/
server.py
File metadata and controls
5859 lines (5478 loc) · 351 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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
"""ViralLab — Engineer your influence. Turn noise into viral."""
import os
import subprocess
import sys
import threading
import time
import io
import zipfile
from pathlib import Path
# Load .env so BILIBILI_PROXY etc. work when running server directly
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
except ImportError:
pass
import json
from typing import Optional
# Bypass proxy for news/video fetches (fixes "connection refused" when proxy is down)
_PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy")
def _ensure_macos_system_proxy_in_env(env: dict) -> dict:
"""If env has no HTTP(S)_proxy, copy macOS system proxy from scutil (QuickQ / gost / Clash)."""
if env.get("HTTP_PROXY") or env.get("HTTPS_PROXY"):
return env
if sys.platform != "darwin":
return env
try:
r = subprocess.run(
["scutil", "--proxy"],
capture_output=True,
text=True,
timeout=2,
cwd=Path(__file__).parent,
)
if r.returncode == 0 and r.stdout and ("HTTPEnable : 1" in r.stdout or "HTTPSEnable : 1" in r.stdout):
host = port = None
for line in r.stdout.splitlines():
s = line.strip()
if s.startswith("HTTPProxy :"):
host = s.split(":", 1)[1].strip()
elif s.startswith("HTTPSProxy :"):
host = host or s.split(":", 1)[1].strip()
elif s.startswith("HTTPPort :"):
port = s.split(":", 1)[1].strip()
elif s.startswith("HTTPSPort :"):
port = port or s.split(":", 1)[1].strip()
if host and port:
env["HTTP_PROXY"] = env["HTTPS_PROXY"] = f"http://{host}:{port}"
except Exception:
pass
return env
def _env_for_bilibili():
"""Env for B站 subprocess: use system proxy so Clash etc. can route bilibili.com to DIRECT (VPN on still works)."""
env = os.environ.copy()
env["PYTHONPATH"] = str(Path(__file__).parent)
return _ensure_macos_system_proxy_in_env(env)
def _env_no_proxy():
env = os.environ.copy()
for v in _PROXY_VARS:
env.pop(v, None)
return env
def _env_for_topic_search_subprocess() -> dict:
"""Env for scripts/search_only.py: keep proxy + macOS scutil; zh multi-source can be slow."""
env = _ensure_macos_system_proxy_in_env(os.environ.copy())
env["PYTHONPATH"] = str(Path(__file__).parent)
return env
def _topic_search_subprocess_timeout_sec() -> int:
try:
t = int(os.environ.get("NEWS_TOPIC_SEARCH_TIMEOUT_SEC", "180"))
except ValueError:
t = 180
return max(60, min(t, 600))
from urllib.parse import quote
from flask import Flask, jsonify, redirect, request, send_from_directory, send_file, make_response
from src.parse_output import parse_file, parse_raw_news
from src.news_sources import raw_topic_file_stem
from src.video_tools import extract_youtube_id, score_berger
from src.content_angles import generate_angles
from src.minto_pyramid import structure_minto, minto_to_markdown
from src.run_history import add_lifecycle_to_items
from src.hot_trending import AUDIENCE_SEGMENTS, rerank_topics_by_segment
from src.media_transcribe import (
download_youtube_video,
has_videocaptioner,
is_supported_external_media_url,
test_douyin_session,
transcribe_best_effort,
)
from src.transcription.whisperx_runner import has_whisperx
from src.image_ocr import ocr_image_bytes, ocr_job_for_url
from src.contagious_stepps import evaluate_contagious_article
from src.article_lab import build_viral_article_markdown
from src.publish_pipeline import (
list_publish_items,
load_markdown_from_output,
publish_item_now,
queue_publish_item,
validate_publish_content,
)
from src.diagnostics import classify_error_message
from src.podcast_rss_sources import (
REGION_TAGS,
classify_focus_theme,
classify_source_type,
fetch_longform_and_podcasts,
region_tags_for_lang,
)
app = Flask(__name__)
OUTPUT = Path(__file__).parent / "output"
_daily_stale_refresh_lock = threading.Lock()
_daily_last_stale_refresh_attempt = 0.0
NEWS_SEARCHES_FILE = OUTPUT / "news_searches.json"
LONGFORM_CACHE_TTL_SEC = 30 * 60
_LONGFORM_REFRESHING = {"en": False, "zh": False}
_virallab_apscheduler = None
_virallab_scheduler_lock = threading.Lock()
# Suggestions only — users can search any topic. Creator-relevant defaults per language zone.
DEFAULT_TOPIC_TIPS = [
"AI agents", "creator economy", "climate tech", "no-code tools", "LLM trends",
"creator tools", "indie hacking", "side projects", "AI explainers", "content strategy",
"viral marketing", "social media trends", "YouTube algorithm", "newsletter monetization",
"Substack trends", "podcast growth", "automation tools", "AI writing", "design tools",
"remote work",
]
DEFAULT_TOPIC_TIPS_ZH = [
"小红书爆款", "抖音运营", "短视频创作", "创作者经济", "直播带货", "私域流量",
"电商直播", "内容营销", "AI写作", "B站UP主", "知乎好物", "微信公众号",
"品牌出海", "小红书种草", "抖音算法", "剪映教程", "飞书文档", "即刻",
"知识星球", "新榜",
]
# Viral Videos — creator-focused topic suggestions per language
DEFAULT_VIRAL_TIPS = [
"AI explainer", "fashion haul", "viral marketing", "unboxing", "makeup tutorial",
"cooking viral", "dance challenge", "prank", "product review", "day in my life",
]
DEFAULT_VIRAL_TIPS_ZH = [
"热门", "鬼畜", "搞笑", "数码", "游戏", "知识", "时尚", "影视",
"汽车", "日常", "穿搭",
]
# Audience segment topic tips (Tier 1: docs/AUDIENCE_INSIGHT_STRATEGY.md)
SEGMENT_TOPIC_TIPS = {
"general": {"en": None, "zh": None}, # use DEFAULT_TOPIC_TIPS / DEFAULT_TOPIC_TIPS_ZH
"tech": {
"en": ["AI agents", "LLM trends", "developer tools", "open source", "SaaS", "product launch", "tech startup", "API", "automation"],
"zh": ["AI", "科技", "产品", "效率工具", "开源", "开发者", "互联网", "数码", "算法"],
},
"entrepreneurs": {
"en": ["indie hacking", "side project", "creator economy", "monetization", "growth", "launch", "startup", "marketing", "remote work"],
"zh": ["创业", "副业", "出海", "变现", "个人品牌", "品牌出海", "运营", "私域流量", "增长"],
},
"creator": {
"en": ["creator economy", "viral marketing", "content strategy", "YouTube algorithm", "newsletter", "Substack", "podcast growth"],
"zh": ["小红书爆款", "抖音运营", "创作者经济", "直播带货", "内容营销", "B站UP主", "种草", "爆款"],
},
}
SEGMENT_VIRAL_TIPS = {
"general": {"en": None, "zh": None},
"tech": {"en": ["AI explainer", "tech review", "developer", "startup"], "zh": ["知识", "数码", "科技"]},
"entrepreneurs": {"en": ["indie hack", "launch", "growth", "side project"], "zh": ["创业", "出海", "变现"]},
"creator": {"en": ["viral marketing", "content strategy", "tutorial"], "zh": ["爆款", "运营", "种草"]},
}
def _record_topic_search(topic: str) -> None:
"""Record a topic search with timestamp for time-based popular searches."""
if not topic or len(topic) > 80:
return
from datetime import datetime, timezone
data = {"searches": []}
if NEWS_SEARCHES_FILE.exists():
try:
data = json.loads(NEWS_SEARCHES_FILE.read_text(encoding="utf-8"))
except Exception:
pass
searches = data.get("searches", [])
# Migrate old format: topics -> searches (no timestamp, treat as recent)
if not searches and data.get("topics"):
now = datetime.now(timezone.utc).isoformat()
for t, c in data["topics"].items():
searches.extend([{"topic": t, "ts": now}] * min(c, 10))
data["topics"] = {}
searches.append({"topic": topic.strip(), "ts": datetime.now(timezone.utc).isoformat()})
# Keep last 30 days
cutoff = datetime.now(timezone.utc).timestamp() - (30 * 24 * 3600)
searches = [s for s in searches if _parse_ts(s.get("ts", "")) > cutoff]
data["searches"] = searches[-5000:]
OUTPUT.mkdir(exist_ok=True)
NEWS_SEARCHES_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def _parse_ts(s: str) -> float:
from datetime import datetime
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")[:26]).timestamp()
except Exception:
return 0
def _has_cjk(s: str) -> bool:
"""True if string contains CJK (Chinese/Japanese/Korean) characters."""
for c in s:
if "\u4e00" <= c <= "\u9fff" or "\u3000" <= c <= "\u303f" or "\uac00" <= c <= "\ud7af":
return True
return False
def _matches_lang(topic: str, lang: str) -> bool:
"""True if topic matches the language zone (no mixing in EN/zh views)."""
has_cjk = _has_cjk(topic)
return (lang == "zh" and has_cjk) or (lang == "en" and not has_cjk)
def _get_segment() -> str:
"""Audience segment from query or cookie. One of: general, tech, entrepreneurs, creator."""
seg = (request.args.get("segment") or request.cookies.get("virallab_segment") or "general").lower()
return seg if seg in ("general", "tech", "entrepreneurs", "creator") else "general"
def _get_platform_hot_topics(
limit: int,
lang: str,
force_refresh: bool = False,
segment: str = "general",
*,
allow_blocking_fetch: bool = True,
) -> tuple[list[str], bool, Optional[int]]:
"""Platform-wide hot topics. ZH: 微博、知乎、抖音、百度. EN: Hacker News, Reddit. Returns (topics, ok, cache_age_sec).
If segment != general, topics are re-ranked by segment relevance. Never blocks >2s."""
import threading
cache_file = OUTPUT / "hot_trending.json"
cache_ttl = 10 * 60 # 10 min
key = "topics_zh" if lang == "zh" else "topics_en"
now = __import__("time").time()
def _run_refresh():
try:
script = Path(__file__).parent / "scripts" / "fetch_hot_trending.py"
env = _env_no_proxy()
env["PYTHONPATH"] = str(Path(__file__).parent)
subprocess.run(
[sys.executable, str(script)],
capture_output=True,
env=env,
cwd=Path(__file__).parent,
timeout=45,
)
except Exception:
pass
# Use fresh cache if available
if not force_refresh and cache_file.exists():
try:
data = json.loads(cache_file.read_text(encoding="utf-8"))
ts = data.get("ts", 0)
topics = data.get(key, data.get("topics", []))
if topics:
if segment != "general":
topics = rerank_topics_by_segment(topics, lang, segment)
if now - ts < cache_ttl:
return topics[:limit], True, int(now - ts)
threading.Thread(target=_run_refresh, daemon=True).start()
return topics[:limit], True, int(now - ts)
except Exception:
pass
# Avoid blocking HTTP handlers (e.g. /news on cold deploy): fill cache in background only.
if not allow_blocking_fetch and not force_refresh:
threading.Thread(target=_run_refresh, daemon=True).start()
return [], False, None
# No usable cache or force_refresh: run sync fetch. User-initiated refresh gets longer timeout (~50s).
sync_timeout = 55 if force_refresh else 8
try:
script = Path(__file__).parent / "scripts" / "fetch_hot_trending.py"
env = _env_no_proxy()
env["PYTHONPATH"] = str(Path(__file__).parent)
subprocess.run(
[sys.executable, str(script)],
capture_output=True,
env=env,
cwd=Path(__file__).parent,
timeout=sync_timeout,
)
if cache_file.exists():
data = json.loads(cache_file.read_text(encoding="utf-8"))
topics = data.get(key, data.get("topics", []))
if topics:
if segment != "general":
topics = rerank_topics_by_segment(topics, lang, segment)
return topics[:limit], True, 0
except Exception:
pass
threading.Thread(target=_run_refresh, daemon=True).start()
return [], False, None
def _get_most_searched(
limit: int = 10,
time_range: str = "1d",
lang: str = "en",
force_hot_refresh: bool = False,
segment: str = "general",
*,
allow_blocking_hot_fetch: bool = False,
) -> tuple[list[str], str, Optional[int]]:
"""Top topics. Returns (topics, source, hot_cache_age_sec). source: 'platform'|'app'|'default'. hot_cache_age only when platform."""
from datetime import datetime, timezone
seg_tips = SEGMENT_TOPIC_TIPS.get(segment, {})
defaults = (seg_tips.get(lang) or (DEFAULT_TOPIC_TIPS_ZH if lang == "zh" else DEFAULT_TOPIC_TIPS))[:limit * 2]
block_fetch = allow_blocking_hot_fetch or force_hot_refresh
platform_topics, ok, cache_age = _get_platform_hot_topics(
limit,
lang,
force_refresh=force_hot_refresh,
segment=segment,
allow_blocking_fetch=block_fetch,
)
if ok:
return platform_topics[:limit], "platform", cache_age
now = datetime.now(timezone.utc).timestamp()
if time_range == "60m":
cutoff = now - 3600
elif time_range == "7d":
cutoff = now - (7 * 24 * 3600)
else:
cutoff = now - (24 * 3600)
if not NEWS_SEARCHES_FILE.exists():
return defaults[:limit], "default", None
try:
data = json.loads(NEWS_SEARCHES_FILE.read_text(encoding="utf-8"))
searches = data.get("searches", [])
if not searches and data.get("topics"):
sorted_topics = sorted(data["topics"].items(), key=lambda x: -x[1])
result = [t[0] for t in sorted_topics[:limit] if _matches_lang(t[0], lang)]
for d in defaults:
if len(result) >= limit:
break
if d not in result:
result.append(d)
return result[:limit], "app" if data["topics"] else "default", None
counts = {}
for s in searches:
ts = _parse_ts(s.get("ts", ""))
if ts >= cutoff:
t = s.get("topic", "").strip()
if t and _matches_lang(t, lang):
counts[t] = counts.get(t, 0) + 1
if not counts:
return defaults[:limit], "default", None
sorted_topics = sorted(counts.items(), key=lambda x: -x[1])
result = [t[0] for t in sorted_topics[:limit]]
for d in defaults:
if len(result) >= limit:
break
if d not in result:
result.append(d)
return result[:limit], "app", None
except Exception:
return defaults[:limit], "default", None
def _news_refresh_max_topics() -> int:
try:
n = int(os.environ.get("NEWS_REFRESH_MAX_TOPICS", "15"))
except ValueError:
n = 15
return max(5, min(n, 40))
def _topics_for_news_refresh() -> list[str]:
"""Queries for /news/refresh — platform hot tags across segments (ZH+EN), deduped by file stem."""
cap = _news_refresh_max_topics()
seen_stems: set[str] = set()
out: list[str] = []
for seg in ("general", "tech", "entrepreneurs", "creator"):
for slang in ("zh", "en"):
topics, _, _ = _get_most_searched(10, "1d", slang, False, seg)
for t in topics or []:
st = raw_topic_file_stem(t)
if st in seen_stems:
continue
seen_stems.add(st)
out.append(t)
if len(out) >= cap:
return out
for slang in ("zh", "en"):
defaults = DEFAULT_TOPIC_TIPS_ZH if slang == "zh" else DEFAULT_TOPIC_TIPS
for d in defaults:
st = raw_topic_file_stem(d)
if st not in seen_stems:
seen_stems.add(st)
out.append(d)
if len(out) >= cap:
return out
return out
def _news_card_rows(
lang: str,
time_range: str,
segment: str,
force_hot: bool,
topic_filter: str,
) -> list[dict]:
"""One card block per live hot tag: stem, markdown file, display label, rescan query."""
if topic_filter:
stem = topic_filter.strip()
disp = stem.replace("_", " ")
return [{"stem": stem, "file": f"raw_{stem}.md", "display_topic": disp, "query_topic": disp}]
tips, _, _ = _get_most_searched(
limit=12,
time_range=time_range,
lang=lang,
force_hot_refresh=force_hot,
segment=segment,
)
rows: list[dict] = []
seen: set[str] = set()
for tag in tips:
stem = raw_topic_file_stem(tag)
if stem in seen:
continue
seen.add(stem)
rows.append({"stem": stem, "file": f"raw_{stem}.md", "display_topic": tag, "query_topic": tag})
if len(rows) < 6:
defaults = DEFAULT_TOPIC_TIPS_ZH if lang == "zh" else DEFAULT_TOPIC_TIPS
for d in defaults:
if len(rows) >= 12:
break
st = raw_topic_file_stem(d)
if st in seen:
continue
seen.add(st)
rows.append({"stem": st, "file": f"raw_{st}.md", "display_topic": d, "query_topic": d})
return rows
def _hot_tags_for_lang(lang: str, limit: int = 10) -> list[str]:
"""Unified hot tags references (EN and ZH kept separate)."""
seg = _get_segment()
tags, _source, _age = _get_most_searched(
limit=max(limit, 10), time_range="1d", lang=lang, force_hot_refresh=False, segment=seg
)
if tags:
return tags[:limit]
return (DEFAULT_TOPIC_TIPS_ZH if lang == "zh" else DEFAULT_TOPIC_TIPS)[:limit]
def _longform_cache_path(lang: str) -> Path:
return OUTPUT / f"longform_cache_{'zh' if lang == 'zh' else 'en'}.json"
def _read_longform_cache(lang: str) -> tuple[list[dict], list[str], float]:
path = _longform_cache_path(lang)
if not path.exists():
return [], [], 0.0
try:
data = json.loads(path.read_text(encoding="utf-8"))
items = data.get("items", [])
sources = data.get("sources", [])
ts = float(data.get("ts") or 0.0)
if isinstance(items, list) and isinstance(sources, list) and ts > 0:
return items, sources, ts
except Exception:
pass
return [], [], 0.0
def _write_longform_cache(lang: str, items: list[dict], sources: list[str]) -> None:
path = _longform_cache_path(lang)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"lang": "zh" if lang == "zh" else "en",
"ts": time.time(),
"items": items,
"sources": sources,
}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _refresh_longform_cache(lang: str, max_per_feed: int = 6) -> None:
try:
items, sources = fetch_longform_and_podcasts(lang=lang, max_per_feed=max_per_feed)
if items:
_write_longform_cache(lang, items, sources)
print(f"[longform] refreshed {lang}: {len(items)} items from {len(sources)} feeds")
else:
print(f"[longform] refresh {lang}: 0 items (check network, proxy, or feedparser)")
except Exception as e:
print(f"[longform] refresh {lang} failed: {e}")
finally:
_LONGFORM_REFRESHING[lang] = False
def _kick_longform_refresh(lang: str, max_per_feed: int = 6) -> None:
if _LONGFORM_REFRESHING.get(lang):
return
_LONGFORM_REFRESHING[lang] = True
threading.Thread(
target=_refresh_longform_cache,
kwargs={"lang": lang, "max_per_feed": max_per_feed},
daemon=True,
).start()
def _get_longform_feed(lang: str, max_per_feed: int = 6, force_refresh: bool = False) -> tuple[list[dict], list[str], bool, int]:
now = time.time()
cached_items, cached_sources, cache_ts = _read_longform_cache(lang)
cache_age = int(now - cache_ts) if cache_ts else -1
cache_ok = bool(cached_items)
if cache_ok and not force_refresh:
if cache_age <= LONGFORM_CACHE_TTL_SEC:
return cached_items, cached_sources, True, cache_age
_kick_longform_refresh(lang, max_per_feed=max_per_feed)
return cached_items, cached_sources, True, cache_age
try:
items, sources = fetch_longform_and_podcasts(lang=lang, max_per_feed=max_per_feed)
if items:
_write_longform_cache(lang, items, sources)
return items, sources, False, 0
except Exception:
pass
if cache_ok:
return cached_items, cached_sources, True, cache_age
return [], [], False, -1
TAGLINE = "Engineer your influence. Turn noise into viral."
SPREAD_LINE = "ViralLab scores content by applying behavioral science - aka. STEPPS, design viral content - so easy"
TG_GROUP_URL = "https://t.me/virallab8"
TG_PERSONAL_URL = "https://t.me/miccakitt"
BLOG_URL = "https://funghi88.github.io/"
GITHUB_REPO_URL = "https://github.com/Funghi88/ViralLab"
X_URL = "https://x.com/miccakitt"
def _html_escape(s):
return (
str(s)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def _sanitize_snippet(text: str) -> str:
"""Strip HTML tags and decode entities so snippets display as plain text, not raw HTML."""
if not text or not isinstance(text, str):
return ""
import html
import re
s = html.unescape(text)
s = re.sub(r"<[^>]*>?", "", s) # strip tags including malformed/unclosed
s = re.sub(r"\s+", " ", s).strip()
return s[:500]
# Snippet placeholders: when snippet equals these, it's source attribution, not news content
_SNIPPET_PLACEHOLDERS = frozenset({"Hacker News front page", "HN", "Hacker News"})
def _snippet_is_placeholder(snippet: str) -> bool:
"""True when snippet is a source placeholder, not actual news content."""
return (snippet or "").strip() in _SNIPPET_PLACEHOLDERS
def _source_display(source: str) -> str:
"""Source attribution for display. Use full name for respect."""
if source == "Hacker News":
return "Hacker News front page"
return source or ""
def _get_source_for_item(item: dict) -> str:
"""Get source for display. When snippet is placeholder (e.g. HN), use it as source."""
src = _source_display(item.get("source", "")) or _infer_source_from_url(item.get("url", ""))
if not src and _snippet_is_placeholder(item.get("snippet", "")):
snip = (item.get("snippet") or "").strip()
if snip == "HN":
return "Hacker News front page"
return snip
return src
def _source_type_for_item(item: dict) -> str:
"""Classify item source to support Daily filters."""
return classify_source_type(_get_source_for_item(item))
def _infer_source_from_url(url: str) -> str:
"""Infer source from URL when source field is missing."""
if not url:
return ""
u = (url or "").lower()
if "news.ycombinator.com" in u:
return "Hacker News front page"
if "theverge.com" in u:
return "The Verge"
if "techcrunch.com" in u:
return "TechCrunch"
if "producthunt.com" in u:
return "Product Hunt"
return ""
def _get_lang():
"""Get user language from cookie or query param. Returns 'en' or 'zh'."""
lang = request.cookies.get("virallab-lang") or request.args.get("lang", "en")
return "zh" if lang == "zh" else "en"
def _get_region():
"""Get user region from URL param (for shared links) or cookie. Returns 'global', 'americas', 'europe', or 'asia'."""
r = request.args.get("region") or request.cookies.get("virallab-region") or "global"
return r if r in ("global", "americas", "europe", "asia") else "global"
def _t(key: str, lang: str) -> str:
"""Translation lookup. Key -> en/zh string."""
T = {
"daily_news_title": ("Daily News", "每日新闻"),
"daily_news_desc": (
"Top 3 = most talked about right now. News, long-form, and podcast sources in one feed.",
"前 3 条为当下最热话题,并整合新闻、长文与播客来源",
),
"daily_focus_label": ("Focus", "聚焦"),
"daily_focus_all": ("All", "全部"),
"daily_focus_news": ("News", "新闻"),
"daily_focus_longform": ("Long-form", "长文"),
"daily_focus_podcast": ("Podcast", "播客"),
"daily_focus_hint": ("Filter cards by source type.", "按来源类型筛选卡片"),
"mechanism": ("", ""),
"refresh_now": ("Refresh now", "立即刷新"),
"refresh_desc": (
"fetch latest anytime. Server refreshes daily digest every 30 min by default; opening this page also triggers a background refresh if the digest is older than ~8h.",
"随时拉取最新。服务器默认约每 30 分钟刷新每日新闻;若摘要超过约 8 小时未更新,打开本页也会在后台自动补跑一次。",
),
"longform_refresh_desc": (
"Cache-first for speed. Refreshes in background after 30 min, on a schedule, and when the server starts.",
"优先读缓存;超过约 30 分钟、定时任务以及每次启动服务后都会在后台重新拉取。",
),
"longform_cache_age": ("Cache age: {mins} min", "缓存时长:{mins} 分钟"),
"last_updated": ("Last updated", "最后更新"),
"sources": ("Sources", "来源"),
"sources_curated": ("curated for content creators.", "为创作者精选"),
"setup": ("SETUP", "设置"),
"view_full_digest": ("View full digest (10 Top News · load more)", "查看完整摘要(10 条头条 · 加载更多)"),
"full_digest_title": ("Full digest (10 Top News · load more)", "完整摘要(10 条头条 · 加载更多)"),
"load_more": ("More", "更多"),
"ranking_legend": ("Rising = new · Peaking = holding strong · Fading = declining", "上升 = 新话题 · 高峰 = 热度持稳 · 回落 = 热度下降"),
"tooltip_rising": ("New this run — wasn't in the previous top list. A fresh topic gaining traction.", "本轮新上榜,上一轮未出现。新兴热门话题"),
"tooltip_peaking": ("Same or better rank than before — the topic is at or near its peak attention.", "排名持平或上升,话题处于或接近关注高峰"),
"tooltip_fading": ("Dropped in rank — was higher before, now declining in buzz.", "排名下降,热度回落"),
"search_by_topic": ("Search by topic", "按主题搜索"),
"whats_happening": ("what's happening around X", "了解 X 领域动态"),
"no_news": ("No daily news yet. Run:", "尚无每日新闻,执行:"),
"source": ("Source", "来源"),
"contagious_score": ("Contagious", "传播性"),
"lifecycle_rising": ("rising", "上升"),
"lifecycle_peaking": ("peaking", "高峰"),
"lifecycle_fading": ("fading", "回落"),
# Homepage & global
"nav_home": ("Home", "首页"),
"nav_home_desc": ("Landing & quick links", "首页与快速链接"),
"nav_daily": ("Daily News", "每日新闻"),
"nav_daily_desc": ("Top 3 topics today", "今日前 3 热门话题"),
"nav_field": ("Your Field", "你的领域"),
"nav_field_desc": ("Curated by industry", "按行业精选"),
"nav_news": ("News by Topic", "按主题新闻"),
"nav_news_desc": ("Search & browse news", "搜索与浏览新闻"),
"nav_longform": ("Long-form & Podcasts", "长文与播客"),
"nav_longform_desc": ("Deep articles, blogs, and podcasts", "深度文章、博客与播客"),
"nav_viral": ("Viral Videos", "热门视频"),
"nav_viral_desc": ("YouTube & Bilibili", "YouTube 与 Bilibili"),
"nav_video2text": ("Video to Text", "视频转文字"),
"nav_video2text_desc": ("Extract transcript from video", "视频提取逐字稿"),
"nav_article_lab": ("Article Lab", "文章改写"),
"nav_article_lab_desc": ("Upload markdown → viral rewrite", "上传 markdown → 结构化爆款改写"),
"nav_publish": ("Publish Queue", "发布队列"),
"nav_publish_desc": ("Approve and publish to channels", "审核并发布到渠道"),
"nav_science": ("STEPPS & Structure", "评分与结构"),
"nav_science_desc": ("Scoring + Minto Pyramid", "评分与金字塔结构"),
"nav_china_content": ("China content", "中国内容"),
"nav_china_content_desc": ("Fetch from Bilibili, XHS, Douyin, Zhihu, Shipinhao", "从B站、小红书、抖音、知乎、视频号拉取"),
"hero_line1": ("ENGINEER YOUR", "打造你的"),
"hero_line2": ("INFLUENCE.", "影响力"),
"hero_subtitle": ("Your content agent.", "你的内容助手"),
"hero_desc": ("Turn noise into viral. Gather trends, score with behavioral science, and design content that spreads.", "化杂讯为爆款。小红书、抖音、B站创作者必备 — 趋势、评分、内容角度"),
"hero_cta": ("Explore Features", "探索功能"),
"search_placeholder": ("Search viral videos (e.g. AI explainer)...", "搜索热门视频(如:AI 解说)..."),
"how_we_score": ("How we score", "评分方式"),
"tg_group": ("TG group", "TG 群组"),
"home_title": ("Home", "首页"),
"tagline": ("Engineer your influence. Turn noise into viral.", "打造你的影响力。化杂讯为爆款"),
"spread_line": ("ViralLab scores content by applying behavioral science - aka. STEPPS, design viral content - so easy", "ViralLab 运用行为科学(STEPPS)评分内容,设计爆款内容更轻松"),
"link_daily": ("Daily News", "每日新闻"),
"link_daily_desc": ("Top 3 most talked about or key focus topics", "前 3 条最热门或重点话题"),
"link_field": ("Your Field", "你的领域"),
"link_field_desc": ("Curated trends and resources by industry", "按行业精选的趋势与资源"),
"link_news": ("News by Topic", "按主题新闻"),
"link_news_desc": ("Search and browse news by topic", "按主题搜索与浏览新闻"),
"link_longform": ("Long-form & Podcasts", "长文与播客"),
"link_longform_desc": ("Deep reads and episodes in one place", "深度阅读与播客聚合"),
"link_viral": ("Viral Videos", "热门视频"),
"link_viral_desc": ("YouTube & Bilibili, ranked by spread rate", "YouTube 与 Bilibili,按传播率排序"),
"link_video2text": ("Video to Text", "视频转文字"),
"link_video2text_desc": ("Paste URL → extract transcript", "粘贴链接 → 提取逐字稿"),
"link_article_lab": ("Article Lab", "文章改写"),
"link_article_lab_desc": ("Upload markdown and rewrite with STEPPS + Minto", "上传 markdown 并用 STEPPS + Minto 改写"),
"link_publish": ("Publish Queue", "发布队列"),
"link_publish_desc": ("Approve drafts and publish to X/blog", "审核草稿并发布到 X/博客"),
"link_science": ("STEPPS & Structure", "评分与结构"),
"link_science_desc": ("Scoring + Minto Pyramid", "评分与金字塔结构"),
# Your Field page
"field_title": ("Your field · Curated resources", "你的领域 · 精选资源"),
"field_desc": ("Pick your niche — must-see trends and resources for Xiaohongshu, Douyin, Bilibili creators. Fashion-first, designer-built.", "选择你的领域 — 小红书、抖音、B站创作者必看的趋势与资源。时尚优先,设计师视角"),
"field_trends": ("Trends", "趋势"),
"field_color_forecasting": ("Color forecasting", "色彩预测"),
"field_forecasting_sites": ("Forecasting sites", "预测网站"),
"field_resources": ("Resources", "资源"),
"field_select_above": ("Select a field above.", "请在上方选择领域"),
"field_empty": ("No resources for this field.", "此领域暂无资源"),
"field_region": ("Region", "地区"),
"field_region_global": ("Global", "全球"),
"field_region_americas": ("Americas", "美洲"),
"field_region_europe": ("Europe", "欧洲"),
"field_region_asia": ("Asia", "亚洲"),
"field_share": ("Share", "分享"),
"field_share_copied": ("Link copied!", "链接已复制!"),
"field_share_to": ("Share to", "分享至"),
"field_share_whatsapp": ("WhatsApp", "WhatsApp"),
"field_share_telegram": ("Telegram", "Telegram"),
"field_share_x": ("X", "X"),
"field_share_linkedin": ("LinkedIn", "LinkedIn"),
"field_share_copy": ("Copy link", "复制链接"),
"field_share_text": ("Curated resources for content creators — ViralLab", "为创作者精选的趋势与资源 — ViralLab"),
# News by topic — different content per language zone
"news_title": ("News by topic", "按主题新闻"),
"news_desc": (
"Each section matches a live tag from above (hot list + segment). Refresh all only re-fetches those tags — not every old file on disk.",
"每个板块对应上方热词(实时热榜 + 当前领域)。「刷新全部」只更新这些标签,不再遍历旧的 raw 主题文件。",
),
"longform_title": ("Long-form & Podcasts", "长文与播客"),
"longform_desc": ("Page sources:", "页面文章的来源:"),
"longform_scope": (
"English mode: global English sources. Chinese mode: Chinese ecosystem, including Traditional Chinese, Singapore, Taiwan, and overseas Chinese communities.",
"英文区:全球英文来源。中文区:中文生态来源(含繁体中文区、新加坡、台湾与海外华人社区)。",
),
"longform_no_items": ("No long-form items yet. Refresh Daily News first.", "暂无长文内容,请先刷新每日新闻。"),
"longform_view_daily": ("Open Daily News", "打开每日新闻"),
"longform_theme_label": ("Focus themes", "主题聚焦"),
"longform_theme_all": ("All", "全部"),
"longform_theme_transition": ("Transition pain points", "转型痛点"),
"longform_theme_one_person": ("One-person business", "一人公司"),
"longform_theme_design_ai": ("Design & creativity in AI", "设计与创意 · AI 时代"),
"news_placeholder": ("Search any topic (e.g. AI agents, creator economy)", "搜索主题(如:小红书爆款、抖音运营)"),
"news_range_60m": ("60 mins", "60 分钟"),
"news_range_1d": ("1 day", "1 天"),
"news_range_7d": ("7 days", "7 天"),
"news_tip_requested": ("Most requested in last {range}", "过去 {range} 最常搜索"),
"audience_segment_label": ("Audience", "受众"),
"news_tip_platform_hot": ("What users care about now — Weibo, Zhihu, Douyin, Baidu, 少数派", "用户正在关注 · 微博、知乎、抖音、百度、少数派 实时热搜"),
"news_tip_platform_hot_en": ("What users care about now — HN, Reddit, Lobsters, Dev.to, GitHub", "用户正在关注 · HN、Reddit、Lobsters、Dev.to、GitHub 实时热门"),
"news_tip_suggestions": ("Suggestions — try these", "建议 — 试试这些"),
"hot_tags_title": ("Hot tags", "热门标签"),
"hot_tags_desc": ("Reference tags from current ecosystem signals.", "来自当前生态信号的参考标签。"),
"news_topic_label": ("Topic", "主题"),
"news_no_results": ("Search a topic above to get curated news.", "搜索上方主题即可获取精选新闻"),
"news_searching": ("Searching… Refresh in a few seconds.", "正在搜索… 几秒后刷新页面"),
"news_english_topic_in_zh": ("This topic has English content. Please select a Chinese topic above.", "此主题为英文内容,请选择上方中文主题"),
"news_chinese_topic_in_en": ("This topic has Chinese content. Switch to 中文 to view.", "此主题为中文内容,请切换至英文查看"),
"news_show_all": ("Show all topics", "显示全部主题"),
"news_search_failed": ("Search failed (proxy/network). Re-run when VPN is on.", "搜索失败(代理/网络)。请开启 VPN 后重试"),
"news_parse_error": ("Could not render this topic file. Try refreshing or re-searching.", "该主题文件无法渲染,请刷新或重新搜索。"),
"news_topic_no_hits": (
"Last search returned no articles for this topic (network, VPN, or source limits). Re-search below.",
"该主题上次搜索未返回文章(网络、VPN 或来源限制)。请在下方重新搜索。",
),
"news_topic_unparsed": (
"This topic file has no article list yet. Re-search below.",
"该主题文件尚无文章列表,请在下方重新搜索。",
),
"news_rescan_topic": ("Re-search this topic", "重新搜索此主题"),
"news_refresh_all": ("Refresh all topics", "刷新全部主题"),
"news_refresh_hot": ("Refresh hot list", "刷新热搜"),
"news_refresh_interval": ("every 60 mins", "每 60 分钟"),
"news_hot_updated": ("Updated {mins} min ago", "约 {mins} 分钟前更新"),
"news_hot_just_now": ("Just updated", "刚刚更新"),
"loading_searching": ("Searching topics…", "正在搜索主题…"),
"loading_search_sub": ("This may take up to a minute. Please wait.", "可能需要一分钟,请稍候"),
"loading_refreshing": ("Refreshing…", "正在刷新…"),
"loading_refresh_sub": ("Please wait.", "请稍候"),
# Viral Videos — different content per language zone
"viral_title": ("Viral Videos", "热门视频"),
"viral_subtitle": ("Ranked by spread rate", "按传播率排序"),
"viral_desc": ("Search by topic and view videos ranked by spread rate.", "按主题搜索并查看按传播率排序的视频。"),
"viral_placeholder": ("e.g. AI explainer, fashion haul", "如:AI 解说、穿搭测评、直播带货"),
"viral_source_label": ("Source", "来源"),
"viral_source_global": ("Global (YouTube)", "全球 (YouTube)"),
"viral_source_china": ("China (Bilibili)", "中国 (Bilibili)"),
"viral_hot_tags_hint": ("Use hot tags as quick query starters.", "可用热门标签作为快速搜索起点。"),
"viral_search_btn": ("Search viral", "搜索热门"),
"viral_what_we_serve": ("Global: YouTube videos. China: Bilibili videos. Every link opens the video on its platform.", "英文区:YouTube 视频。中文区:B站视频。点击即跳转至该平台观看。"),
"viral_china_note": ("In China? YouTube needs VPN. Use China source for Bilibili (no VPN).", "在中国, YouTube 需魔法, 请选中国来源搜 B站"),
"viral_china_fetch_title": ("Fetch content from platforms", "从平台拉取内容"),
"viral_china_fetch_desc": ("Run fetch for selected platforms (Bilibili API + optional crawlers for XHS/Douyin/Zhihu/Shipinhao). Results merge into the list above", "对所选平台运行拉取(B站公开API + 小红书/抖音/知乎/视频号可选爬取),结果会合并到上方列表"),
"china_crawl_duration": ("Crawl took {sec}s", "拉取耗时 {sec} 秒"),
"viral_china_fetch_btn": ("Fetch", "拉取"),
"viral_china_fetch_keywords": ("Keywords", "关键词"),
"viral_china_platforms": ("Platforms", "平台"),
"content_type_video": ("Video", "视频"),
"content_type_post": ("Post", "帖子"),
"content_type_article": ("Article", "文章"),
"content_type_note": ("Note", "笔记"),
"china_crawl_done": ("Crawl finished. Refresh the list to see results.", "拉取完成。请刷新上方列表查看。"),
"china_crawl_no_items": ("No items fetched.", "未拉取到内容。"),
"china_crawl_errors": ("({n} platform(s) failed — see below)", "({n} 个平台失败 — 见下方)"),
"china_content_page_title": ("China content", "中国内容"),
"china_content_after_fetch": ("Fetched content appears below. You can also see it on the Viral page (source: China).", "拉取结果展示于本页下方;也可在「热门视频」选中国来源查看。"),
"china_content_audience_label": ("Audience — add their keywords as tags below", "人群 — 点击后将该人群关心的关键词加入下方 tag"),
"china_content_keyword_input_placeholder": ("Type a hot keyword (e.g. open claw), Enter or Add as tag", "输入你发现的热门关键词(如 open claw),回车或点击添加成 tag"),
"china_content_add_tag": ("Add", "添加"),
"china_content_tags_label": ("Keywords to crawl (click × to remove)", "将要爬取的关键词(点击 × 移除)"),
"china_content_no_tags_yet": ("No keywords yet — pick an audience above or type your own", "暂无关键词 — 点击上方人群或输入自定义"),
"china_content_try_tags": ("Select one or more keywords, then Fetch to crawl all together (max 10).", "勾选一个或多个关键词,点击拉取可一并爬取(最多10个)。"),
"china_content_fetched_title": ("Fetched content", "拉取结果"),
"china_content_no_items_yet": ("No content yet. Run Fetch above (requires Playwright in the same Python that runs the server).", "暂无内容。请在上方点击拉取(需在运行服务器的同一 Python 环境中安装 Playwright)。"),
"china_content_eval_btn": ("Score & rewrite", "评估 · 改写"),
"china_content_eval_score": ("Score", "评分"),
"china_content_rewrite_directions": ("Rewrite directions", "改写方向"),
"china_content_minto_title": ("Structure (Minto)", "结构(金字塔)"),
"china_content_eval_loading": ("Evaluating…", "评估中…"),
"china_content_eval_error": ("Evaluation failed", "评估失败"),
"china_content_crawl_hint": ("Bilibili uses public API (no login). Xiaohongshu/Douyin often return no data without login.", "B站使用公开API(无需登录);小红书/抖音未登录时多为空。"),
"china_content_login_title": ("Login (optional)", "登录态(可选)"),
"china_content_login_desc": ("Without login, XHS/Douyin often return no data. To use your session: run the command below on your machine; a browser will open — log in there once. Session is saved locally only; we never upload it.", "未登录时小红书/抖音等可能无法拉取。若需登录后拉取:请在本机终端运行下方命令,会打开浏览器,在页面中登录一次即可。会话仅保存在本机 config/,我们不会上传你的登录信息。"),
"china_content_login_cmd": (".venv/bin/python -m tools.china_crawler login --platform xhs", ".venv/bin/python -m tools.china_crawler login --platform xhs"),
"china_content_login_cmd_note": ("Replace xhs with douyin, zhihu, or shipinhao for other platforms. Bilibili does not need login.", "将 xhs 改为 douyin、zhihu 或 shipinhao 可保存其他平台登录态。B站无需登录。"),
"china_content_login_risk": ("Using your account to crawl may be detected by the platform (rate limit or ban). We recommend a separate account; we do not post or delete your content. See docs/CHINA_CRAWLER_LOGIN.md.", "使用账号登录爬取可能被平台检测、限流或封号,建议使用小号。本工具不会代你发帖/删帖,不会删除你账号内容;详见 docs/CHINA_CRAWLER_LOGIN.md。"),
"china_content_free_compliant_title": ("Free & compliant", "免费且合规"),
"china_content_hot_topics_label": ("Today's hot topics (click to add as keyword)", "今日最热话题(点击加入为关键词)"),
"china_content_free_compliant_desc": ("No cost, no scraping, no login: use Bilibili (public API) here, and open the links below to search on each platform in your browser. Fully compliant.", "不付费、不爬取、不登录:B站用本站公开 API;其余平台点击链接在浏览器中搜索,合规使用。"),
"china_content_free_bilibili": ("Bilibili (in-app)", "B站(站内)"),
"china_content_free_search_links": ("Search in browser", "在浏览器中搜索"),
"china_content_load_error": ("China content page failed to load.", "中国内容页暂时无法加载。"),
"viral_china_content_link": ("Fetch from Bilibili, 小红书, 抖音, 知乎, 视频号", "从B站、小红书、抖音、知乎、视频号拉取内容"),
"viral_suggestions": ("Suggestions — try these", "建议 — 试试这些"),
"viral_lang_hint": ("This topic is in English. Try China source for Chinese content.", "此主题为英文,建议选择中国来源搜索中文内容"),
"viral_berger_unproven": ("—", "—"),
"viral_berger_methodology": ("Score: title+description only (Berger keywords). Not AI. 0 views = unproven.", "评分:仅标题与描述(Berger 关键字)。非 AI。0 播放 = 未验证"),
"viral_loading": ("Finding what's spreading…", "正在加载…"),
"viral_empty_title": ("Nothing here yet.", "暂无结果"),
"viral_empty_body": ("Try one of these searches to see viral content scored in real time:", "试试这些搜索,实时查看爆款评分:"),
"viral_error": ("Error", "加载失败"),
"viral_china_unavailable": ("Bilibili may be unreachable from your network. Try switching to Global (YouTube) source above.", "B站 API 可能无法访问(需在中国网络)。请切换至上方「全球 (YouTube)」来源重试。"),
"viral_china_proxy_placeholder": (
"e.g. http://127.0.0.1:18080 (gost / same as Cursor)",
"本机 HTTP 前端,如 http://127.0.0.1:18080(gost,与 Cursor 一致)",
),
"viral_china_retry_with_proxy": ("Retry", "重试"),
"viral_timeout": ("Request timeout", "请求超时"),
"content_angles": ("3 ways to ride this trend", "3 种内容角度"),
# Video to Text — different content per language zone
"video2text_title": ("Video to Text", "影片转文字"),
"video2text_tagline": ("Paste a video. Get the transcript.", "粘贴视频。获取逐字稿"),
"video2text_accuracy_note": (
"**Douyin:** save the clip to disk and paste **local video/audio path** here (recommended — URL fetch breaks often without heavy cookie setup). YouTube prefers existing captions first. Other URLs use yt-dlp / VideoCaptioner; optional WhisperX for speech-only.",
"**抖音(推荐):** 先把视频**保存到本机**,在下面粘贴 **`/路径/文件名.mp4`**(或拖拽得到的路径);不要在抖音依赖网页链接。**YouTube:** 优先现成字幕。**其它:** yt-dlp / VideoCaptioner,可选 WhisperX 语音转文字。",
),
"video2text_placeholder": (
"Paste local path e.g. /Users/you/Movies/clip.mp4 or a YouTube / Bilibili / XHS URL",
"可粘贴本地文件路径(如 `/Users/you/Movies/抖音.mp4`)或 YouTube/B站/小红书等链接(抖音不推荐用链接)",
),
"video2text_btn": ("Get transcript →", "获取逐字稿 →"),
"douyin_session_test_btn": ("Douyin URL test (optional)", "可选:抖音链接自检"),
"douyin_session_testing": ("Testing Douyin URL...", "正在检测抖音链接…"),
"video2text_hint": (
"Local file path recommended for Douyin. Also: YouTube, Bilibili, Xiaohongshu, Shipinhao, Xiaoyuzhou, podcast links · e.g. ",
"抖音建议用**本地视频路径**;也支持 YouTube、B站、小红书、视频号、小宇宙、播客链接 · 例如 ",
),
"video2text_note_minto": ("After you get a transcript, use the <strong>Structure (Minto)</strong> tab above the transcript to see conclusion → key points → evidence.", "获取逐字稿后,用上方的<strong>结构(金字塔)</strong>标签查看结论 → 要点 → 论据。"),
"video2text_tab_structure": ("Structure (Minto)", "结构(金字塔)"),
"video2text_export_mode": ("Export", "导出"),
"video2text_export_raw": ("Original transcript", "原文版"),
"video2text_export_minto": ("Minto version", "金字塔版"),
"video2text_goto_article_lab": ("Rewrite this transcript →", "用这份逐字稿去改写 →"),
"article_lab_prefilled_from": ("Prefilled from saved file", "已从保存的逐字稿载入"),
"article_lab_prefill_missing": (
"Could not load that file from output — paste markdown or upload below.",
"未能从 output 载入该文件,请粘贴正文或上传。",
),
"video2text_processing": ("Processing your transcript…", "正在处理逐字稿…"),
"video2text_processing_sub": ("This can take a few seconds for captions and longer for ASR. Please keep this page open.", "字幕模式通常几秒,ASR 模式会更久。请保持页面开启。"),
"video2text_err_douyin": (
"Douyin URL fetch failed. Prefer a **local downloaded file**: paste `/path/to/video.mp4` here and retry. Cookie-based URL fetching is brittle.",
"抖音**链接**拉取失败。**推荐**改贴**本地下载的视频路径**(如 `/Movies/片段.mp4`)再获取逐字稿;依赖 Cookie 在线拉取很不稳定。",
),
"video2text_err_xhs": (
"Xiaohongshu: the note must include video (not image-only). If it still fails, open the note in Chrome while logged in and retry, or set YTDLP_COOKIES_BROWSER / YTDLP_COOKIES_PROFILE. Align terminal HTTP_PROXY with Cursor: http://127.0.0.1:18080 (gost); avoid stale QuickQ ports. Short links retry without a broken proxy.",
"小红书:需为视频笔记(纯图文无法转写)。若仍失败,请在已登录的 Chrome 中打开该笔记后重试,或配置 YTDLP_COOKIES_BROWSER / YTDLP_COOKIES_PROFILE。终端 HTTP_PROXY 建议与 Cursor 一致使用 http://127.0.0.1:18080(gost),勿沿用已失效的 QuickQ 口;短链在代理不可连时会尝试直连。",
),
"video2text_err_generic": (
"Transcript unavailable for this link right now.",
"暂时无法获取该链接的逐字稿。",
),
"video2text_your_transcripts": ("Your transcripts", "你的逐字稿"),
"video2text_none": ("None yet", "尚无"),
"video2text_download_btn": ("Download video", "下载视频"),
"video2text_download_progress": ("Downloading video…", "正在下载视频…"),
"video2text_download_hint": (
"YouTube only: uses yt-dlp (<code>pip install yt-dlp</code>). Respect copyright and YouTube's terms.",
"仅 YouTube:使用 yt-dlp(<code>pip install yt-dlp</code>)。请遵守版权与 YouTube 条款。",
),
"youtube_dl_missing_url": ("Paste a URL first.", "请先粘贴链接。"),
"youtube_dl_need_ytdlp": (
"yt-dlp is not installed. Run: pip install yt-dlp",
"未检测到 yt-dlp。请运行:pip install yt-dlp",
),
"youtube_dl_not_youtube": (
"Only YouTube watch or youtu.be links are supported for download.",
"下载仅支持 YouTube 或 youtu.be 链接。",
),
"youtube_dl_failed": ("Could not download this video.", "无法下载该视频。"),
"article_lab_title": ("Article Lab", "文章改写"),
"article_lab_desc": ("Upload or paste markdown. We output a structured contagious version in markdown.", "上传或粘贴 markdown,输出结构化传播版本(markdown)。"),
"article_lab_intro": (
"Rewrite your markdown into a structured, contagious version with Minto + STEPPS.",
"将 markdown 改写为更有结构、更可传播的版本(Minto + STEPPS)。",
),
"article_lab_input_label": ("Input markdown", "输入 markdown"),
"article_lab_upload_multi_label": ("Upload multiple .md files (batch)", "批量上传多个 .md 文件"),
"article_lab_btn": ("Rewrite article", "改写文章"),
"article_lab_btn_batch": ("Batch rewrite", "批量改写"),
"article_lab_download": ("Download rewritten markdown", "下载改写后的 markdown"),
"article_lab_queue_publish": ("Queue to publish", "加入发布队列"),
"publish_title": ("Publish Queue", "发布队列"),
"publish_desc": ("Queue rewritten content, approve, then publish to X or blog.", "将改写内容加入队列,审核后发布到 X 或博客。"),
"publish_handoff_title": ("WeChat MP & Xiaohongshu (Cursor)", "微信公众号与小红书(Cursor)"),
"publish_handoff_body": (
"Finish markdown here, then in Cursor ask the Agent to run global skills **wechat-mp-auto** or **xiaohongshu-auto** (browser → mp.weixin.qq.com / creator.xiaohongshu.com). Copy from below or open a saved file under /view/…. Skills live in ~/cursor-skills-source after link-to-cursor.sh.",
"在此定稿 markdown 后,到 Cursor 让 Agent 执行全局技能 **wechat-mp-auto** 或 **xiaohongshu-auto**(浏览器打开 mp.weixin.qq.com / creator.xiaohongshu.com)。从下方复制正文,或通过 /view/文件名 打开 output 中的 .md。技能在 ~/cursor-skills-source,需已执行 link-to-cursor.sh。",
),
"publish_form_title": ("Create publish draft", "创建发布草稿"),
"publish_platform": ("Platform", "平台"),
"publish_to_x": ("X", "X"),
"publish_to_blog": ("Blog", "博客"),
"publish_schedule": ("Schedule (optional)", "定时(可选)"),
"publish_add_btn": ("Add to queue", "加入队列"),
"publish_now_btn": ("Publish now", "立即发布"),
"publish_empty": ("No publish items yet.", "暂无发布项。"),
"publish_status_queued": ("Queued", "待发布"),
"publish_status_published": ("Published", "已发布"),
"publish_status_failed": ("Failed", "失败"),
# Campaign page
"campaign_eyebrow": ("ViralLab · Free · No API key needed", "ViralLab · 免费 · 无需 API 密钥"),
"campaign_h1_line1": ("Stop guessing", "停止猜测"),
"campaign_h1_line2": ("why content ", "为什么内容会"),
"campaign_h1_em": ("spreads.", "爆红"),
"campaign_h1_cta": ("Now you know.", "现在你知道了"),
"campaign_hero_sub1": ("You're one score away", "一个分数就能"),
"campaign_hero_sub2": (" from understanding any viral video, trend, or idea. ViralLab gives you the same framework Fortune 500 brands use — powered by Jonah Berger's peer-reviewed science — ", "让你读懂任何爆款视频、趋势或创意。ViralLab 给你 Fortune 500 品牌用的同一套框架 — 基于 Jonah Berger 的同行评审科学 — "),
"campaign_hero_sub3": ("free, in seconds.", "免费,几秒搞定"),
"campaign_cta_score": ("Score a video now", "立即评分视频"),
"campaign_cta_trends": ("See today's trends", "看今日热门"),
"campaign_proof_books": ("Based on 4 NYT bestsellers", "基于 4 本纽约时报畅销书"),
"campaign_proof_studies": ("80+ peer-reviewed studies", "80+ 篇同行评审研究"),
"campaign_proof_brands": ("Used by Apple, Google, Nike", "Apple、Google、Nike 都在用"),
"campaign_proof_free": ("Free — no keys, no signup", "免费 — 无密钥、免注册"),
"campaign_tag_story": ("The story behind this tool", "这个工具背后的故事"),
"campaign_story_before": ("Before", "从前"),
"campaign_story_before_h": ("You publish.<br>You hope.", "你发布。<br>你祈祷"),
"campaign_story_before_b": ("You watch other creators blow up on the same topics you covered. You guess. You copy. You wonder why yours never lands the same way.", "你看着别人用同样的主题爆红。你猜。你模仿。你不懂为什么你的永远差一点。"),
"campaign_story_disc": ("Discovery", "发现"),
"campaign_story_disc_h": ("Virality isn't<br>luck. It's science.", "爆红不是<br>运气。是科学"),