From 502ace5dce3861f195c5a8bc12e5ee18b0d137fb Mon Sep 17 00:00:00 2001 From: Susuhome Date: Sun, 8 Mar 2026 16:20:45 +0800 Subject: [PATCH] feat: YouTube Community Management (#70) - CommentManager: sentiment analysis, spam detection, response generation, language detection, batch analysis - PostScheduler: community post scheduling, campaigns, cross-platform, due detection - SpamDetector: 9 pattern rules with scoring, severity levels, recommended actions - Guide: guides/youtube_community.md - 24 tests, all passing --- lux/guides/youtube_community.md | 47 +++++++ .../youtube/community/comment_manager.ex | 126 ++++++++++++++++++ .../youtube/community/post_scheduler.ex | 104 +++++++++++++++ .../prisms/youtube/community/spam_detector.ex | 65 +++++++++ .../community/comment_manager_test.exs | 69 ++++++++++ .../youtube/community/post_scheduler_test.exs | 50 +++++++ .../youtube/community/spam_detector_test.exs | 48 +++++++ 7 files changed, 509 insertions(+) create mode 100644 lux/guides/youtube_community.md create mode 100644 lux/lib/lux/prisms/youtube/community/comment_manager.ex create mode 100644 lux/lib/lux/prisms/youtube/community/post_scheduler.ex create mode 100644 lux/lib/lux/prisms/youtube/community/spam_detector.ex create mode 100644 lux/test/unit/lux/prisms/youtube/community/comment_manager_test.exs create mode 100644 lux/test/unit/lux/prisms/youtube/community/post_scheduler_test.exs create mode 100644 lux/test/unit/lux/prisms/youtube/community/spam_detector_test.exs diff --git a/lux/guides/youtube_community.md b/lux/guides/youtube_community.md new file mode 100644 index 00000000..deafb66b --- /dev/null +++ b/lux/guides/youtube_community.md @@ -0,0 +1,47 @@ +# YouTube Community Management Guide + +## Comment Manager + +```elixir +{:ok, pid} = CommentManager.start_link() + +# Analyze comments (sentiment, spam, language, questions) +{:ok, analysis} = CommentManager.analyze_comment(pid, %{id: "c1", text: "Amazing video!", author: "Alice"}) +# %{sentiment: :positive, is_spam: false, is_question: false, language: "en"} + +# Auto-generate responses +{:ok, resp} = CommentManager.generate_response(pid, %{text: "How does this work?"}) +# %{response: %{action: :reply, text: "Great question! Let me look into that."}} + +# Batch analysis +{:ok, results} = CommentManager.batch_analyze(pid, comments) +{:ok, stats} = CommentManager.get_stats(pid) +``` + +## Post Scheduler + +```elixir +{:ok, pid} = PostScheduler.start_link() + +# Schedule community posts +PostScheduler.schedule_post(pid, %{text: "New video tomorrow!", scheduled_at: ~U[2026-03-10 12:00:00Z]}) + +# Cross-platform +PostScheduler.schedule_post(pid, %{text: "Big news!", platforms: [:youtube, :twitter]}) + +# Campaigns +PostScheduler.create_campaign(pid, %{name: "Summer Launch", start_date: ~D[2026-06-01]}) + +# Get due posts +{:ok, due} = PostScheduler.get_due_posts(pid) +``` + +## Spam Detector + +```elixir +result = SpamDetector.detect("Sub4sub check my channel!") +# %{is_spam: true, score: 75, severity: :critical, recommended_action: :ban_and_delete} + +batch = SpamDetector.batch_detect(["Good comment", "SPAM sub4sub click bit.ly/x"]) +# %{total: 2, spam_count: 1, spam_rate: 50.0} +``` diff --git a/lux/lib/lux/prisms/youtube/community/comment_manager.ex b/lux/lib/lux/prisms/youtube/community/comment_manager.ex new file mode 100644 index 00000000..57270615 --- /dev/null +++ b/lux/lib/lux/prisms/youtube/community/comment_manager.ex @@ -0,0 +1,126 @@ +defmodule Lux.Prisms.YouTube.Community.CommentManager do + @moduledoc """ + Comment analysis, response generation, and management for YouTube. + """ + + use GenServer + + defstruct [:comments, :responses, :stats] + + def start_link(opts \\ []) do + name = opts[:name] || __MODULE__ + GenServer.start_link(__MODULE__, opts, name: name) + end + + def analyze_comment(pid \\ __MODULE__, comment) do + GenServer.call(pid, {:analyze, comment}) + end + + def generate_response(pid \\ __MODULE__, comment) do + GenServer.call(pid, {:generate_response, comment}) + end + + def batch_analyze(pid \\ __MODULE__, comments) do + GenServer.call(pid, {:batch_analyze, comments}) + end + + def get_stats(pid \\ __MODULE__) do + GenServer.call(pid, :stats) + end + + @impl true + def init(_opts) do + {:ok, %__MODULE__{comments: [], responses: [], stats: %{total: 0, positive: 0, negative: 0, neutral: 0, spam: 0}}} + end + + @impl true + def handle_call({:analyze, comment}, _from, state) do + analysis = do_analyze(comment) + stats = update_stats(state.stats, analysis) + {:reply, {:ok, analysis}, %{state | comments: [analysis | state.comments], stats: stats}} + end + + @impl true + def handle_call({:generate_response, comment}, _from, state) do + analysis = do_analyze(comment) + response = build_response(analysis) + {:reply, {:ok, %{comment: comment, analysis: analysis, response: response}}, + %{state | responses: [response | state.responses]}} + end + + @impl true + def handle_call({:batch_analyze, comments}, _from, state) do + results = Enum.map(comments, &do_analyze/1) + stats = Enum.reduce(results, state.stats, &update_stats(&2, &1)) + {:reply, {:ok, results}, %{state | comments: results ++ state.comments, stats: stats}} + end + + @impl true + def handle_call(:stats, _from, state) do + {:reply, {:ok, state.stats}, state} + end + + defp do_analyze(comment) do + text = comment[:text] || "" + sentiment = analyze_sentiment(text) + is_spam = is_spam?(text) + is_question = String.contains?(text, "?") + + %{ + id: comment[:id], + text: text, + author: comment[:author], + sentiment: sentiment, + is_spam: is_spam, + is_question: is_question, + word_count: length(String.split(text, ~r/\s+/, trim: true)), + has_link: Regex.match?(~r/https?:\/\//, text), + language: detect_language(text) + } + end + + defp analyze_sentiment(text) do + words = text |> String.downcase() |> String.split(~r/\W+/, trim: true) + pos = ~w(love great awesome amazing good excellent best wonderful fantastic helpful) + neg = ~w(hate bad terrible awful worst horrible poor ugly disappointing spam) + p = Enum.count(words, &(&1 in pos)) + n = Enum.count(words, &(&1 in neg)) + cond do + p > n -> :positive + n > p -> :negative + true -> :neutral + end + end + + defp is_spam?(text) do + t = String.downcase(text) + spam_patterns = [~r/check my channel/, ~r/sub4sub/, ~r/free \w+ at/, ~r/click here/, ~r/bit\.ly/] + Enum.any?(spam_patterns, &Regex.match?(&1, t)) + end + + defp detect_language(text) do + cond do + Regex.match?(~r/[\p{Han}]/u, text) -> "zh" + Regex.match?(~r/[\p{Hiragana}\p{Katakana}]/u, text) -> "ja" + Regex.match?(~r/[\p{Hangul}]/u, text) -> "ko" + true -> "en" + end + end + + defp build_response(analysis) do + cond do + analysis.is_spam -> %{action: :hide, text: nil} + analysis.is_question -> %{action: :reply, text: "Great question! Let me look into that."} + analysis.sentiment == :positive -> %{action: :heart, text: "Thank you so much! πŸ™"} + analysis.sentiment == :negative -> %{action: :reply, text: "Sorry to hear that. Could you share more details so we can improve?"} + true -> %{action: :none, text: nil} + end + end + + defp update_stats(stats, analysis) do + stats + |> Map.update!(:total, &(&1 + 1)) + |> Map.update!(analysis.sentiment, &(&1 + 1)) + |> then(fn s -> if analysis.is_spam, do: Map.update!(s, :spam, &(&1 + 1)), else: s end) + end +end diff --git a/lux/lib/lux/prisms/youtube/community/post_scheduler.ex b/lux/lib/lux/prisms/youtube/community/post_scheduler.ex new file mode 100644 index 00000000..3fce36cf --- /dev/null +++ b/lux/lib/lux/prisms/youtube/community/post_scheduler.ex @@ -0,0 +1,104 @@ +defmodule Lux.Prisms.YouTube.Community.PostScheduler do + @moduledoc """ + Community post scheduling and campaign management. + """ + + use GenServer + + defstruct [:posts, :campaigns] + + def start_link(opts \\ []) do + name = opts[:name] || __MODULE__ + GenServer.start_link(__MODULE__, opts, name: name) + end + + def schedule_post(pid \\ __MODULE__, post) do + GenServer.call(pid, {:schedule, post}) + end + + def cancel_post(pid \\ __MODULE__, post_id) do + GenServer.call(pid, {:cancel, post_id}) + end + + def get_due_posts(pid \\ __MODULE__) do + GenServer.call(pid, :get_due) + end + + def create_campaign(pid \\ __MODULE__, campaign) do + GenServer.call(pid, {:create_campaign, campaign}) + end + + def list_posts(pid \\ __MODULE__) do + GenServer.call(pid, :list_posts) + end + + def list_campaigns(pid \\ __MODULE__) do + GenServer.call(pid, :list_campaigns) + end + + @impl true + def init(_opts), do: {:ok, %__MODULE__{posts: [], campaigns: []}} + + @impl true + def handle_call({:schedule, post}, _from, state) do + id = post[:id] || gen_id() + entry = %{ + id: id, + text: post[:text], + image_url: post[:image_url], + poll: post[:poll], + scheduled_at: post[:scheduled_at], + campaign_id: post[:campaign_id], + status: :scheduled, + platforms: post[:platforms] || [:youtube] + } + posts = [entry | state.posts] |> Enum.sort_by(& &1.scheduled_at, DateTime) + {:reply, {:ok, entry}, %{state | posts: posts}} + end + + @impl true + def handle_call({:cancel, post_id}, _from, state) do + case Enum.find(state.posts, &(&1.id == post_id)) do + nil -> {:reply, {:error, :not_found}, state} + _ -> + posts = Enum.reject(state.posts, &(&1.id == post_id)) + {:reply, :ok, %{state | posts: posts}} + end + end + + @impl true + def handle_call(:get_due, _from, state) do + now = DateTime.utc_now() + {due, remaining} = Enum.split_with(state.posts, fn p -> + p.status == :scheduled && DateTime.compare(p.scheduled_at, now) in [:lt, :eq] + end) + {:reply, {:ok, due}, %{state | posts: remaining}} + end + + @impl true + def handle_call({:create_campaign, campaign}, _from, state) do + id = campaign[:id] || gen_id() + entry = %{ + id: id, + name: campaign[:name], + description: campaign[:description] || "", + start_date: campaign[:start_date], + end_date: campaign[:end_date], + status: :active, + created_at: DateTime.utc_now() + } + {:reply, {:ok, entry}, %{state | campaigns: [entry | state.campaigns]}} + end + + @impl true + def handle_call(:list_posts, _from, state) do + {:reply, {:ok, state.posts}, state} + end + + @impl true + def handle_call(:list_campaigns, _from, state) do + {:reply, {:ok, state.campaigns}, state} + end + + defp gen_id, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) +end diff --git a/lux/lib/lux/prisms/youtube/community/spam_detector.ex b/lux/lib/lux/prisms/youtube/community/spam_detector.ex new file mode 100644 index 00000000..3a32ec35 --- /dev/null +++ b/lux/lib/lux/prisms/youtube/community/spam_detector.ex @@ -0,0 +1,65 @@ +defmodule Lux.Prisms.YouTube.Community.SpamDetector do + @moduledoc """ + Spam detection and moderation for YouTube comments. + """ + + @spam_patterns [ + {~r/sub4sub|sub 4 sub/i, 40}, + {~r/check (out )?my channel/i, 35}, + {~r/free \w+ at \w+\.\w+/i, 40}, + {~r/click (the )?link/i, 30}, + {~r/bit\.ly|tinyurl|t\.co/i, 25}, + {~r/([\x{1F600}-\x{1F64F}].*){5,}/u, 15}, + {~r/(.)\1{4,}/, 20}, + {~r/https?:\/\/\S+/i, 10}, + {~r/[A-Z\s]{20,}/, 15} + ] + + @doc "Detect spam in a comment. Returns score and matched patterns." + def detect(text) when is_binary(text) do + matches = @spam_patterns + |> Enum.filter(fn {pattern, _} -> Regex.match?(pattern, text) end) + |> Enum.map(fn {pattern, score} -> %{pattern: Regex.source(pattern), score: score} end) + + total_score = Enum.sum(Enum.map(matches, & &1.score)) + + severity = cond do + total_score >= 60 -> :critical + total_score >= 40 -> :high + total_score >= 20 -> :medium + total_score > 0 -> :low + true -> :none + end + + %{ + is_spam: total_score >= 30, + score: total_score, + severity: severity, + matches: matches, + recommended_action: recommended_action(severity) + } + end + + @doc "Batch detect spam." + def batch_detect(texts) when is_list(texts) do + results = Enum.map(texts, fn t -> + text = if is_binary(t), do: t, else: t[:text] || "" + {text, detect(text)} + end) + + spam_count = Enum.count(results, fn {_, r} -> r.is_spam end) + + %{ + results: results, + total: length(results), + spam_count: spam_count, + spam_rate: if(length(results) > 0, do: Float.round(spam_count / length(results) * 100, 1), else: 0.0) + } + end + + defp recommended_action(:critical), do: :ban_and_delete + defp recommended_action(:high), do: :delete + defp recommended_action(:medium), do: :hold_for_review + defp recommended_action(:low), do: :flag + defp recommended_action(:none), do: :allow +end diff --git a/lux/test/unit/lux/prisms/youtube/community/comment_manager_test.exs b/lux/test/unit/lux/prisms/youtube/community/comment_manager_test.exs new file mode 100644 index 00000000..d57d8914 --- /dev/null +++ b/lux/test/unit/lux/prisms/youtube/community/comment_manager_test.exs @@ -0,0 +1,69 @@ +defmodule Lux.Prisms.YouTube.Community.CommentManagerTest do + use ExUnit.Case, async: true + alias Lux.Prisms.YouTube.Community.CommentManager + + setup do + name = :"cm_#{:rand.uniform(1_000_000)}" + {:ok, pid} = CommentManager.start_link(name: name) + %{pid: pid} + end + + test "analyze positive comment", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{id: "c1", text: "This is amazing and wonderful!", author: "Alice"}) + assert a.sentiment == :positive + refute a.is_spam + end + + test "analyze negative comment", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{text: "This is terrible and awful"}) + assert a.sentiment == :negative + end + + test "detect question", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{text: "How do I install this?"}) + assert a.is_question + end + + test "detect spam", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{text: "Check my channel for free stuff!"}) + assert a.is_spam + end + + test "detect link", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{text: "Visit https://example.com"}) + assert a.has_link + end + + test "detect Chinese", %{pid: pid} do + {:ok, a} = CommentManager.analyze_comment(pid, %{text: "這個視頻ε€ͺζ£’δΊ†"}) + assert a.language == "zh" + end + + test "generate response for question", %{pid: pid} do + {:ok, r} = CommentManager.generate_response(pid, %{text: "How does this work?"}) + assert r.response.action == :reply + end + + test "generate response for spam", %{pid: pid} do + {:ok, r} = CommentManager.generate_response(pid, %{text: "Sub4sub check my channel"}) + assert r.response.action == :hide + end + + test "generate response for positive", %{pid: pid} do + {:ok, r} = CommentManager.generate_response(pid, %{text: "This is excellent!"}) + assert r.response.action == :heart + end + + test "batch analyze", %{pid: pid} do + comments = [%{text: "Great!"}, %{text: "Terrible"}, %{text: "Normal day"}] + {:ok, results} = CommentManager.batch_analyze(pid, comments) + assert length(results) == 3 + end + + test "stats accumulate", %{pid: pid} do + CommentManager.analyze_comment(pid, %{text: "Amazing!"}) + CommentManager.analyze_comment(pid, %{text: "Horrible"}) + {:ok, stats} = CommentManager.get_stats(pid) + assert stats.total == 2 + end +end diff --git a/lux/test/unit/lux/prisms/youtube/community/post_scheduler_test.exs b/lux/test/unit/lux/prisms/youtube/community/post_scheduler_test.exs new file mode 100644 index 00000000..84b6b469 --- /dev/null +++ b/lux/test/unit/lux/prisms/youtube/community/post_scheduler_test.exs @@ -0,0 +1,50 @@ +defmodule Lux.Prisms.YouTube.Community.PostSchedulerTest do + use ExUnit.Case, async: true + alias Lux.Prisms.YouTube.Community.PostScheduler + + setup do + name = :"ps_#{:rand.uniform(1_000_000)}" + {:ok, pid} = PostScheduler.start_link(name: name) + %{pid: pid} + end + + test "schedule post", %{pid: pid} do + {:ok, post} = PostScheduler.schedule_post(pid, %{text: "Hello!", scheduled_at: DateTime.utc_now() |> DateTime.add(3600)}) + assert post.status == :scheduled + end + + test "cancel post", %{pid: pid} do + {:ok, post} = PostScheduler.schedule_post(pid, %{text: "Cancel me", scheduled_at: DateTime.utc_now()}) + :ok = PostScheduler.cancel_post(pid, post.id) + {:ok, posts} = PostScheduler.list_posts(pid) + assert posts == [] + end + + test "cancel not found", %{pid: pid} do + assert {:error, :not_found} = PostScheduler.cancel_post(pid, "nope") + end + + test "get due posts", %{pid: pid} do + past = DateTime.utc_now() |> DateTime.add(-60) + future = DateTime.utc_now() |> DateTime.add(3600) + {:ok, _} = PostScheduler.schedule_post(pid, %{text: "Due", scheduled_at: past}) + {:ok, _} = PostScheduler.schedule_post(pid, %{text: "Not due", scheduled_at: future}) + {:ok, due} = PostScheduler.get_due_posts(pid) + assert length(due) == 1 + assert hd(due).text == "Due" + end + + test "create campaign", %{pid: pid} do + {:ok, c} = PostScheduler.create_campaign(pid, %{name: "Summer promo", start_date: ~D[2026-06-01]}) + assert c.name == "Summer promo" + {:ok, campaigns} = PostScheduler.list_campaigns(pid) + assert length(campaigns) == 1 + end + + test "cross-platform scheduling", %{pid: pid} do + {:ok, post} = PostScheduler.schedule_post(pid, %{ + text: "Multi-platform!", scheduled_at: DateTime.utc_now(), platforms: [:youtube, :twitter, :instagram] + }) + assert :twitter in post.platforms + end +end diff --git a/lux/test/unit/lux/prisms/youtube/community/spam_detector_test.exs b/lux/test/unit/lux/prisms/youtube/community/spam_detector_test.exs new file mode 100644 index 00000000..2c5b4ae9 --- /dev/null +++ b/lux/test/unit/lux/prisms/youtube/community/spam_detector_test.exs @@ -0,0 +1,48 @@ +defmodule Lux.Prisms.YouTube.Community.SpamDetectorTest do + use ExUnit.Case, async: true + alias Lux.Prisms.YouTube.Community.SpamDetector + + test "clean comment" do + result = SpamDetector.detect("Great video, learned a lot!") + refute result.is_spam + assert result.severity == :none + assert result.recommended_action == :allow + end + + test "sub4sub spam" do + result = SpamDetector.detect("Sub4sub check my channel!") + assert result.is_spam + assert result.severity in [:high, :critical] + end + + test "link spam" do + result = SpamDetector.detect("Free iPhone at bit.ly/scam123") + assert result.is_spam + assert result.score >= 30 + end + + test "all caps spam" do + result = SpamDetector.detect("SUBSCRIBE TO MY CHANNEL NOW!!!") + assert result.score > 0 + end + + test "batch detect" do + texts = ["Great video!", "Sub4sub check my channel", "Normal comment", "Click the link bit.ly/x"] + result = SpamDetector.batch_detect(texts) + assert result.total == 4 + assert result.spam_count >= 2 + assert result.spam_rate > 0 + end + + test "severity levels" do + assert SpamDetector.detect("hi").severity == :none + assert SpamDetector.detect("Sub4sub check my channel click here bit.ly/x").severity in [:critical, :high] + end + + test "recommended actions" do + clean = SpamDetector.detect("Nice video!") + assert clean.recommended_action == :allow + spam = SpamDetector.detect("Sub4sub free stuff at bit.ly/spam click here") + assert spam.recommended_action in [:ban_and_delete, :delete] + end +end