feat: Consolidate Web Search, News Search, and RSS Reader into unified component (#9975)

* feat: Consolidate Web Search, News Search, and RSS Reader into unified component

Merge three separate components into a single Web Search component with tab-based mode selection:
- Web mode: DuckDuckGo search functionality (default)
- News mode: Google News RSS feed search with topic/location support
- RSS mode: Generic RSS feed reader

This reduces code duplication and provides a more intuitive user experience with a single component that handles all search-related functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

* Update test_web_search.py

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Eric Hare <ericrhare@gmail.com>
This commit is contained in:
Rodrigo Nader
2025-09-30 18:54:56 -03:00
committed by GitHub
parent 0d3c7d9fc3
commit 181237f0e8
7 changed files with 623 additions and 476 deletions

View File

@ -1,88 +0,0 @@
from unittest.mock import Mock, patch
import pytest
import requests
from lfx.components.data.news_search import NewsSearchComponent
from lfx.schema import DataFrame
from tests.base import ComponentTestBaseWithoutClient
class TestNewsSearchComponent(ComponentTestBaseWithoutClient):
@pytest.fixture
def component_class(self):
return NewsSearchComponent
@pytest.fixture
def default_kwargs(self):
return {"query": "OpenAI"}
@pytest.fixture
def file_names_mapping(self):
return []
def test_successful_news_search(self):
# Mock Google News RSS feed content
mock_rss_content = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Test News 1</title>
<link>https://example.com/1</link>
<pubDate>2024-03-20</pubDate>
<description>Summary 1</description>
</item>
<item>
<title>Test News 2</title>
<link>https://example.com/2</link>
<pubDate>2024-03-21</pubDate>
<description>Summary 2</description>
</item>
</channel>
</rss>
"""
mock_response = Mock()
mock_response.content = mock_rss_content.encode("utf-8")
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
component = NewsSearchComponent(query="OpenAI")
result = component.search_news()
assert isinstance(result, DataFrame)
news_results_df = result
assert len(news_results_df) == 2
assert list(news_results_df.columns) == ["title", "link", "published", "summary"]
assert news_results_df.iloc[0]["title"] == "Test News 1"
assert news_results_df.iloc[1]["title"] == "Test News 2"
def test_news_search_error(self):
with patch("requests.get", side_effect=requests.RequestException("Network error")):
component = NewsSearchComponent(query="OpenAI")
result = component.search_news()
assert isinstance(result, DataFrame)
news_results_df = result
assert len(news_results_df) == 1
assert news_results_df.iloc[0]["title"] == "Error"
assert "Network error" in news_results_df.iloc[0]["summary"]
def test_empty_news_results(self):
# Mock empty RSS feed
mock_rss_content = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
</channel>
</rss>
"""
mock_response = Mock()
mock_response.content = mock_rss_content.encode("utf-8")
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
component = NewsSearchComponent(query="OpenAI")
result = component.search_news()
assert isinstance(result, DataFrame)
news_results_df = result
assert len(news_results_df) == 1
assert news_results_df.iloc[0]["title"] == "No articles found"

View File

@ -1,129 +0,0 @@
from unittest.mock import Mock, patch
import pytest
import requests
from lfx.components.data.rss import RSSReaderComponent
from lfx.schema import DataFrame
from tests.base import ComponentTestBaseWithoutClient
class TestRSSReaderComponent(ComponentTestBaseWithoutClient):
@pytest.fixture
def component_class(self):
"""Return the component class to test."""
return RSSReaderComponent
@pytest.fixture
def default_kwargs(self):
"""Return the default kwargs for the component."""
return {
"rss_url": "https://example.com/feed.xml",
}
@pytest.fixture
def file_names_mapping(self):
"""Return an empty list since this component doesn't have version-specific files."""
return []
def test_successful_rss_fetch(self):
# Mock RSS feed content
mock_rss_content = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Test Article 1</title>
<link>https://example.com/1</link>
<pubDate>2024-03-20</pubDate>
<description>Test summary 1</description>
</item>
<item>
<title>Test Article 2</title>
<link>https://example.com/2</link>
<pubDate>2024-03-21</pubDate>
<description>Test summary 2</description>
</item>
</channel>
</rss>
"""
# Mock the requests.get response
mock_response = Mock()
mock_response.content = mock_rss_content.encode("utf-8")
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
component = RSSReaderComponent(rss_url="https://example.com/feed.xml")
result = component.read_rss()
assert isinstance(result, DataFrame)
assert len(result) == 2
assert list(result.columns) == ["title", "link", "published", "summary"]
assert result.iloc[0]["title"] == "Test Article 1"
assert result.iloc[1]["title"] == "Test Article 2"
def test_rss_fetch_with_missing_fields(self):
# Mock RSS feed content with missing fields
mock_rss_content = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<item>
<title>Test Article</title>
<!-- Missing link -->
<pubDate>2024-03-20</pubDate>
<!-- Missing description -->
</item>
</channel>
</rss>
"""
mock_response = Mock()
mock_response.content = mock_rss_content.encode("utf-8")
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
component = RSSReaderComponent(rss_url="https://example.com/feed.xml")
result = component.read_rss()
assert isinstance(result, DataFrame)
assert len(result) == 1
assert result.iloc[0]["title"] == "Test Article"
assert result.iloc[0]["link"] == ""
assert result.iloc[0]["summary"] == ""
def test_rss_fetch_error(self):
# Mock a failed request
with patch("requests.get", side_effect=requests.RequestException("Network error")):
component = RSSReaderComponent(rss_url="https://example.com/feed.xml")
result = component.read_rss()
assert isinstance(result, DataFrame)
assert len(result) == 1
assert result.iloc[0]["title"] == "Error"
assert result.iloc[0]["link"] == ""
assert result.iloc[0]["published"] == ""
assert "Network error" in result.iloc[0]["summary"]
def test_empty_rss_feed(self):
# Mock empty RSS feed
mock_rss_content = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
</channel>
</rss>
"""
mock_response = Mock()
mock_response.content = mock_rss_content.encode("utf-8")
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
component = RSSReaderComponent(rss_url="https://example.com/feed.xml")
result = component.read_rss()
assert isinstance(result, DataFrame)
assert len(result) == 0
assert list(result.columns) == ["title", "link", "published", "summary"]

View File

@ -1,3 +1,6 @@
from unittest.mock import Mock, patch
import pandas as pd
import pytest
from lfx.components.data.web_search import WebSearchComponent
from lfx.schema import DataFrame
@ -15,7 +18,9 @@ class TestWebSearchComponent(ComponentTestBaseWithoutClient):
def default_kwargs(self):
"""Return the default kwargs for the component."""
return {
"search_mode": "Web",
"query": "OpenAI GPT-4",
"timeout": 5,
}
@pytest.fixture
@ -24,19 +29,406 @@ class TestWebSearchComponent(ComponentTestBaseWithoutClient):
return []
async def test_invalid_url_handling(self):
# Create a test instance of the component
"""Test invalid URL handling."""
component = WebSearchComponent()
# Set an invalid URL
# Test invalid URL
invalid_url = "htp://invalid-url"
# Ensure the URL is invalid
with pytest.raises(ValueError, match="Invalid URL"):
component.ensure_url(invalid_url)
def test_successful_web_search(self):
def test_validate_url(self):
"""Test URL validation."""
component = WebSearchComponent()
component.query = "OpenAI GPT-4"
result = component.perform_search()
# Valid URLs
assert component.validate_url("https://example.com")
assert component.validate_url("http://example.com")
assert component.validate_url("www.example.com")
assert component.validate_url("example.com")
assert component.validate_url("https://subdomain.example.co.uk")
# Invalid URLs
assert not component.validate_url("not a url at all")
assert not component.validate_url("://missing-protocol")
def test_ensure_url(self):
"""Test ensure_url adds protocol if missing."""
component = WebSearchComponent()
assert component.ensure_url("https://example.com") == "https://example.com"
assert component.ensure_url("http://example.com") == "http://example.com"
assert component.ensure_url("example.com") == "https://example.com"
assert component.ensure_url("www.example.com") == "https://www.example.com"
def test_sanitize_query(self):
"""Test query sanitization."""
component = WebSearchComponent()
# Test removal of dangerous characters
assert component._sanitize_query('<script>alert("test")</script>') == "scriptalert(test)/script"
assert component._sanitize_query("test'query\"with<dangerous>chars") == "testquerywithdangerouschars"
assert component._sanitize_query(" normal query ") == "normal query"
def test_clean_html(self):
"""Test HTML cleaning."""
component = WebSearchComponent()
html = '<p>This is <b>bold</b> text with <a href="#">link</a></p>'
expected = "This is bold text with link"
assert component.clean_html(html) == expected
# Test with complex HTML
html = "<div><h1>Title</h1><p>Paragraph</p></div>"
expected = "Title Paragraph"
assert component.clean_html(html) == expected
def test_update_build_config_web_mode(self):
"""Test build config update for Web mode."""
component = WebSearchComponent()
build_config = {"query": {"info": "", "display_name": ""}}
result = component.update_build_config(build_config, "Web", "search_mode")
assert result["query"]["info"] == "Keywords to search for"
assert result["query"]["display_name"] == "Search Query"
def test_update_build_config_news_mode(self):
"""Test build config update for News mode."""
component = WebSearchComponent()
build_config = {"query": {"info": "", "display_name": ""}}
result = component.update_build_config(build_config, "News", "search_mode")
assert result["query"]["info"] == "Search keywords for news articles."
assert result["query"]["display_name"] == "Search Query"
def test_update_build_config_rss_mode(self):
"""Test build config update for RSS mode."""
component = WebSearchComponent()
build_config = {"query": {"info": "", "display_name": ""}}
result = component.update_build_config(build_config, "RSS", "search_mode")
assert result["query"]["info"] == "RSS feed URL to parse"
assert result["query"]["display_name"] == "RSS Feed URL"
@patch("lfx.components.data.web_search.requests.get")
def test_perform_web_search_success(self, mock_get):
"""Test successful web search."""
component = WebSearchComponent()
component.query = "test query"
component.timeout = 5
# Mock DuckDuckGo response
mock_response = Mock()
mock_response.text = """
<html>
<div class="result">
<a class="result__a" href="?uddg=https%3A%2F%2Fexample.com">Test Title</a>
<a class="result__snippet">Test snippet content</a>
</div>
</html>
"""
mock_response.headers = {"content-type": "text/html"}
mock_response.raise_for_status.return_value = None
# Mock the page fetch
mock_page_response = Mock()
mock_page_response.text = "<html><body>Page content</body></html>"
mock_page_response.raise_for_status.return_value = None
mock_get.side_effect = [mock_response, mock_page_response]
result = component.perform_web_search()
assert isinstance(result, DataFrame)
assert not result.empty
assert len(result) == 1
assert result.iloc[0]["title"] == "Test Title"
assert result.iloc[0]["snippet"] == "Test snippet content"
assert "Page content" in result.iloc[0]["content"]
@patch("lfx.components.data.web_search.requests.get")
def test_perform_web_search_no_results(self, mock_get):
"""Test web search with no results."""
component = WebSearchComponent()
component.query = "test query"
component.timeout = 5
mock_response = Mock()
mock_response.text = ""
mock_response.headers = {"content-type": "text/html"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_web_search()
assert isinstance(result, DataFrame)
assert "No results found" in result.iloc[0]["snippet"]
@patch("lfx.components.data.web_search.requests.get")
def test_perform_web_search_request_error(self, mock_get):
"""Test web search with request error."""
component = WebSearchComponent()
component.query = "test query"
component.timeout = 5
from requests import RequestException
mock_get.side_effect = RequestException("Connection error")
result = component.perform_web_search()
assert isinstance(result, DataFrame)
assert "Connection error" in result.iloc[0]["snippet"]
@patch("lfx.components.data.web_search.requests.get")
def test_perform_news_search_with_query(self, mock_get):
"""Test news search with query."""
component = WebSearchComponent()
component.query = "test news"
component.timeout = 5
# Mock RSS response
mock_response = Mock()
mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<item>
<title>Test News Title</title>
<link>https://news.example.com</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<description>Test news description</description>
</item>
</channel>
</rss>
"""
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_news_search()
assert isinstance(result, DataFrame)
assert len(result) == 1
assert result.iloc[0]["title"] == "Test News Title"
assert result.iloc[0]["link"] == "https://news.example.com"
assert result.iloc[0]["summary"] == "Test news description"
@patch("lfx.components.data.web_search.requests.get")
def test_perform_news_search_with_topic(self, mock_get):
"""Test news search with topic."""
component = WebSearchComponent()
component.topic = "TECHNOLOGY"
component.timeout = 5
# Mock RSS response
mock_response = Mock()
mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<item>
<title>Tech News</title>
<link>https://tech.example.com</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<description>Technology news</description>
</item>
</channel>
</rss>
"""
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_news_search()
assert isinstance(result, DataFrame)
assert len(result) == 1
# Check that the URL was constructed with topic
mock_get.assert_called_once()
call_args = mock_get.call_args[0][0]
assert "topic/TECHNOLOGY" in call_args
@patch("lfx.components.data.web_search.requests.get")
def test_perform_news_search_no_params(self, mock_get): # noqa: ARG002
"""Test news search with no parameters."""
component = WebSearchComponent()
component.timeout = 5
result = component.perform_news_search()
assert isinstance(result, DataFrame)
assert "No search parameters provided" in result.iloc[0]["summary"]
@patch("lfx.components.data.web_search.requests.get")
def test_perform_rss_read_success(self, mock_get):
"""Test successful RSS feed reading."""
component = WebSearchComponent()
component.query = "https://example.com/feed.rss"
component.timeout = 5
# Mock RSS response
mock_response = Mock()
mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<item>
<title>RSS Item 1</title>
<link>https://example.com/item1</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<description>Description 1</description>
</item>
<item>
<title>RSS Item 2</title>
<link>https://example.com/item2</link>
<pubDate>Tue, 02 Jan 2024 00:00:00 GMT</pubDate>
<description>Description 2</description>
</item>
</channel>
</rss>
"""
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_rss_read()
assert isinstance(result, DataFrame)
assert len(result) == 2
assert result.iloc[0]["title"] == "RSS Item 1"
assert result.iloc[1]["title"] == "RSS Item 2"
@patch("lfx.components.data.web_search.requests.get")
def test_perform_rss_read_empty_response(self, mock_get):
"""Test RSS read with empty response."""
component = WebSearchComponent()
component.query = "https://example.com/feed.rss"
component.timeout = 5
mock_response = Mock()
mock_response.content = b""
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_rss_read()
assert isinstance(result, DataFrame)
assert "Empty response received" in result.iloc[0]["summary"]
@patch("lfx.components.data.web_search.requests.get")
def test_perform_rss_read_invalid_xml(self, mock_get):
"""Test RSS read with invalid XML - returns empty DataFrame when no items found."""
component = WebSearchComponent()
component.query = "https://example.com/feed.rss"
component.timeout = 5
mock_response = Mock()
mock_response.content = b"This is not valid XML"
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_rss_read()
assert isinstance(result, DataFrame)
# When no RSS items are found, it returns an empty DataFrame
assert len(result) == 0
def test_perform_rss_read_no_url(self):
"""Test RSS read with no URL provided."""
component = WebSearchComponent()
component.query = ""
result = component.perform_rss_read()
assert isinstance(result, DataFrame)
assert "No RSS URL provided" in result.iloc[0]["summary"]
@patch.object(WebSearchComponent, "perform_web_search")
def test_perform_search_web_mode(self, mock_web_search):
"""Test perform_search routes to web search in Web mode."""
component = WebSearchComponent()
component.search_mode = "Web"
mock_web_search.return_value = DataFrame(pd.DataFrame([{"result": "web"}]))
result = component.perform_search()
mock_web_search.assert_called_once()
assert result.iloc[0]["result"] == "web"
@patch.object(WebSearchComponent, "perform_news_search")
def test_perform_search_news_mode(self, mock_news_search):
"""Test perform_search routes to news search in News mode."""
component = WebSearchComponent()
component.search_mode = "News"
mock_news_search.return_value = DataFrame(pd.DataFrame([{"result": "news"}]))
result = component.perform_search()
mock_news_search.assert_called_once()
assert result.iloc[0]["result"] == "news"
@patch.object(WebSearchComponent, "perform_rss_read")
def test_perform_search_rss_mode(self, mock_rss_read):
"""Test perform_search routes to RSS read in RSS mode."""
component = WebSearchComponent()
component.search_mode = "RSS"
mock_rss_read.return_value = DataFrame(pd.DataFrame([{"result": "rss"}]))
result = component.perform_search()
mock_rss_read.assert_called_once()
assert result.iloc[0]["result"] == "rss"
@patch.object(WebSearchComponent, "perform_web_search")
def test_perform_search_fallback(self, mock_web_search):
"""Test perform_search falls back to web search for unknown mode."""
component = WebSearchComponent()
component.search_mode = "UnknownMode"
mock_web_search.return_value = DataFrame(pd.DataFrame([{"result": "fallback"}]))
result = component.perform_search()
mock_web_search.assert_called_once()
assert result.iloc[0]["result"] == "fallback"
def test_empty_query_error(self):
"""Test that empty query raises ValueError."""
component = WebSearchComponent()
component.query = ""
component.timeout = 5
with pytest.raises(ValueError, match="Empty search query"):
component.perform_web_search()
@patch("lfx.components.data.web_search.requests.get")
def test_news_search_with_location(self, mock_get):
"""Test news search with location parameter."""
component = WebSearchComponent()
component.location = "San Francisco"
component.timeout = 5
# Mock RSS response
mock_response = Mock()
mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<item>
<title>Local News</title>
<link>https://local.example.com</link>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<description>San Francisco news</description>
</item>
</channel>
</rss>
"""
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = component.perform_news_search()
assert isinstance(result, DataFrame)
# Check that the URL was constructed with location
mock_get.assert_called_once()
call_args = mock_get.call_args[0][0]
assert "geo/San%20Francisco" in call_args or "geo/San+Francisco" in call_args

View File

@ -10,9 +10,6 @@ if TYPE_CHECKING:
from lfx.components.data.directory import DirectoryComponent
from lfx.components.data.file import FileComponent
from lfx.components.data.json_to_data import JSONToDataComponent
from lfx.components.data.news_search import NewsSearchComponent
from lfx.components.data.rss import RSSReaderComponent
from lfx.components.data.save_file import SaveToFileComponent
from lfx.components.data.sql_executor import SQLComponent
from lfx.components.data.url import URLComponent
from lfx.components.data.web_search import WebSearchComponent
@ -24,10 +21,7 @@ _dynamic_imports = {
"DirectoryComponent": "directory",
"FileComponent": "file",
"JSONToDataComponent": "json_to_data",
"NewsSearchComponent": "news_search",
"RSSReaderComponent": "rss",
"SQLComponent": "sql_executor",
"SaveToFileComponent": "save_file",
"URLComponent": "url",
"WebSearchComponent": "web_search",
"WebhookComponent": "webhook",
@ -39,10 +33,7 @@ __all__ = [
"DirectoryComponent",
"FileComponent",
"JSONToDataComponent",
"NewsSearchComponent",
"RSSReaderComponent",
"SQLComponent",
"SaveToFileComponent",
"URLComponent",
"WebSearchComponent",
"WebhookComponent",

View File

@ -1,164 +0,0 @@
from urllib.parse import quote_plus
import pandas as pd
import requests
from bs4 import BeautifulSoup
from lfx.custom import Component
from lfx.io import IntInput, MessageTextInput, Output
from lfx.schema import DataFrame
class NewsSearchComponent(Component):
display_name = "News Search"
description = "Searches Google News via RSS. Returns clean article data."
documentation: str = "https://docs.langflow.org/components-data#news-search"
icon = "newspaper"
name = "NewsSearch"
inputs = [
MessageTextInput(
name="query",
display_name="Search Query",
info="Search keywords for news articles.",
tool_mode=True,
required=True,
),
MessageTextInput(
name="hl",
display_name="Language (hl)",
info="Language code, e.g. en-US, fr, de. Default: en-US.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="gl",
display_name="Country (gl)",
info="Country code, e.g. US, FR, DE. Default: US.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="ceid",
display_name="Country:Language (ceid)",
info="e.g. US:en, FR:fr. Default: US:en.",
tool_mode=False,
value="US:en",
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="topic",
display_name="Topic",
info="One of: WORLD, NATION, BUSINESS, TECHNOLOGY, ENTERTAINMENT, SCIENCE, SPORTS, HEALTH.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="location",
display_name="Location (Geo)",
info="City, state, or country for location-based news. Leave blank for keyword search.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
IntInput(
name="timeout",
display_name="Timeout",
info="Timeout for the request in seconds.",
value=5,
required=False,
advanced=True,
),
]
outputs = [Output(name="articles", display_name="News Articles", method="search_news")]
def search_news(self) -> DataFrame:
# Defaults
hl = getattr(self, "hl", None) or "en-US"
gl = getattr(self, "gl", None) or "US"
ceid = getattr(self, "ceid", None) or f"{gl}:{hl.split('-')[0]}"
topic = getattr(self, "topic", None)
location = getattr(self, "location", None)
query = getattr(self, "query", None)
# Build base URL
if topic:
# Topic-based feed
base_url = f"https://news.google.com/rss/headlines/section/topic/{quote_plus(topic.upper())}"
params = f"?hl={hl}&gl={gl}&ceid={ceid}"
rss_url = base_url + params
elif location:
# Location-based feed
base_url = f"https://news.google.com/rss/headlines/section/geo/{quote_plus(location)}"
params = f"?hl={hl}&gl={gl}&ceid={ceid}"
rss_url = base_url + params
elif query:
# Keyword search feed
base_url = "https://news.google.com/rss/search?q="
query_parts = [query]
query_encoded = quote_plus(" ".join(query_parts))
params = f"&hl={hl}&gl={gl}&ceid={ceid}"
rss_url = f"{base_url}{query_encoded}{params}"
else:
self.status = "No search query, topic, or location provided."
self.log(self.status)
return DataFrame(
pd.DataFrame(
[
{
"title": "Error",
"link": "",
"published": "",
"summary": "No search query, topic, or location provided.",
}
]
)
)
try:
response = requests.get(rss_url, timeout=self.timeout)
response.raise_for_status()
soup = BeautifulSoup(response.content, "xml")
items = soup.find_all("item")
except requests.RequestException as e:
self.status = f"Failed to fetch news: {e}"
self.log(self.status)
return DataFrame(pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": str(e)}]))
except (AttributeError, ValueError, TypeError) as e:
self.status = f"Unexpected error: {e!s}"
self.log(self.status)
return DataFrame(pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": str(e)}]))
if not items:
self.status = "No news articles found."
self.log(self.status)
return DataFrame(pd.DataFrame([{"title": "No articles found", "link": "", "published": "", "summary": ""}]))
articles = []
for item in items:
try:
title = self.clean_html(item.title.text if item.title else "")
link = item.link.text if item.link else ""
published = item.pubDate.text if item.pubDate else ""
summary = self.clean_html(item.description.text if item.description else "")
articles.append({"title": title, "link": link, "published": published, "summary": summary})
except (AttributeError, ValueError, TypeError) as e:
self.log(f"Error parsing article: {e!s}")
continue
df_articles = pd.DataFrame(articles)
self.log(f"Found {len(df_articles)} articles.")
return DataFrame(df_articles)
def clean_html(self, html_string: str) -> str:
return BeautifulSoup(html_string, "html.parser").get_text(separator=" ", strip=True)

View File

@ -1,69 +0,0 @@
import pandas as pd
import requests
from bs4 import BeautifulSoup
from lfx.custom import Component
from lfx.io import IntInput, MessageTextInput, Output
from lfx.log.logger import logger
from lfx.schema import DataFrame
class RSSReaderComponent(Component):
display_name = "RSS Reader"
description = "Fetches and parses an RSS feed."
documentation: str = "https://docs.langflow.org/components-data#rss-reader"
icon = "rss"
name = "RSSReaderSimple"
inputs = [
MessageTextInput(
name="rss_url",
display_name="RSS Feed URL",
info="URL of the RSS feed to parse.",
tool_mode=True,
required=True,
),
IntInput(
name="timeout",
display_name="Timeout",
info="Timeout for the RSS feed request.",
value=5,
advanced=True,
),
]
outputs = [Output(name="articles", display_name="Articles", method="read_rss")]
def read_rss(self) -> DataFrame:
try:
response = requests.get(self.rss_url, timeout=self.timeout)
response.raise_for_status()
if not response.content.strip():
msg = "Empty response received"
raise ValueError(msg)
# Check if the response is valid XML
try:
BeautifulSoup(response.content, "xml")
except Exception as e:
msg = f"Invalid XML response: {e}"
raise ValueError(msg) from e
soup = BeautifulSoup(response.content, "xml")
items = soup.find_all("item")
except (requests.RequestException, ValueError) as e:
self.status = f"Failed to fetch RSS: {e}"
return DataFrame(pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": str(e)}]))
articles = [
{
"title": item.title.text if item.title else "",
"link": item.link.text if item.link else "",
"published": item.pubDate.text if item.pubDate else "",
"summary": item.description.text if item.description else "",
}
for item in items
]
# Ensure the DataFrame has the correct columns even if empty
df_articles = pd.DataFrame(articles, columns=["title", "link", "published", "summary"])
logger.info(f"Fetched {len(df_articles)} articles.")
return DataFrame(df_articles)

View File

@ -1,43 +1,133 @@
"""Unified Web Search Component.
This component consolidates Web Search, News Search, and RSS Reader into a single
component with tabs for different search modes.
"""
import re
from urllib.parse import parse_qs, unquote, urlparse
from typing import Any
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
import pandas as pd
import requests
from bs4 import BeautifulSoup
from lfx.custom import Component
from lfx.io import IntInput, MessageTextInput, Output
from lfx.io import IntInput, MessageTextInput, Output, TabInput
from lfx.schema import DataFrame
from lfx.utils.request_utils import get_user_agent
class WebSearchComponent(Component):
display_name = "Web Search"
description = "Performs a basic DuckDuckGo search (HTML scraping). May be subject to rate limits."
description = "Search the web, news, or RSS feeds."
documentation: str = "https://docs.langflow.org/components-data#web-search"
icon = "search"
name = "WebSearchNoAPI"
name = "UnifiedWebSearch"
inputs = [
TabInput(
name="search_mode",
display_name="Search Mode",
options=["Web", "News", "RSS"],
info="Choose search mode: Web (DuckDuckGo), News (Google News), or RSS (Feed Reader)",
value="Web",
real_time_refresh=True,
tool_mode=True,
),
MessageTextInput(
name="query",
display_name="Search Query",
info="Keywords to search for.",
info="Search keywords for news articles.",
tool_mode=True,
required=True,
),
MessageTextInput(
name="hl",
display_name="Language (hl)",
info="Language code, e.g. en-US, fr, de. Default: en-US.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="gl",
display_name="Country (gl)",
info="Country code, e.g. US, FR, DE. Default: US.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="ceid",
display_name="Country:Language (ceid)",
info="e.g. US:en, FR:fr. Default: US:en.",
tool_mode=False,
value="US:en",
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="topic",
display_name="Topic",
info="One of: WORLD, NATION, BUSINESS, TECHNOLOGY, ENTERTAINMENT, SCIENCE, SPORTS, HEALTH.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
MessageTextInput(
name="location",
display_name="Location (Geo)",
info="City, state, or country for location-based news. Leave blank for keyword search.",
tool_mode=False,
input_types=[],
required=False,
advanced=True,
),
IntInput(
name="timeout",
display_name="Timeout",
info="Timeout for the web search request.",
info="Timeout for the request in seconds.",
value=5,
required=False,
advanced=True,
),
]
outputs = [Output(name="results", display_name="Search Results", method="perform_search")]
outputs = [Output(name="results", display_name="Results", method="perform_search")]
def __init__(self, **kwargs):
super().__init__(**kwargs)
def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:
"""Update input visibility based on search mode."""
if field_name == "search_mode":
# Show/hide inputs based on search mode
is_news = field_value == "News"
is_rss = field_value == "RSS"
# Update query field info based on mode
if is_rss:
build_config["query"]["info"] = "RSS feed URL to parse"
build_config["query"]["display_name"] = "RSS Feed URL"
elif is_news:
build_config["query"]["info"] = "Search keywords for news articles."
build_config["query"]["display_name"] = "Search Query"
else: # Web
build_config["query"]["info"] = "Keywords to search for"
build_config["query"]["display_name"] = "Search Query"
# Keep news-specific fields as advanced (matching original News Search component)
# They remain advanced=True in all modes, just like in the original component
return build_config
def validate_url(self, string: str) -> bool:
"""Validate URL format."""
url_regex = re.compile(
r"^(https?:\/\/)?" r"(www\.)?" r"([a-zA-Z0-9.-]+)" r"(\.[a-zA-Z]{2,})?" r"(:\d+)?" r"(\/[^\s]*)?$",
re.IGNORECASE,
@ -45,6 +135,7 @@ class WebSearchComponent(Component):
return bool(url_regex.match(string))
def ensure_url(self, url: str) -> str:
"""Ensure URL has proper protocol."""
if not url.startswith(("http://", "https://")):
url = "https://" + url
if not self.validate_url(url):
@ -54,14 +145,19 @@ class WebSearchComponent(Component):
def _sanitize_query(self, query: str) -> str:
"""Sanitize search query."""
# Remove potentially dangerous characters
return re.sub(r'[<>"\']', "", query.strip())
def perform_search(self) -> DataFrame:
def clean_html(self, html_string: str) -> str:
"""Remove HTML tags from text."""
return BeautifulSoup(html_string, "html.parser").get_text(separator=" ", strip=True)
def perform_web_search(self) -> DataFrame:
"""Perform DuckDuckGo web search."""
query = self._sanitize_query(self.query)
if not query:
msg = "Empty search query"
raise ValueError(msg)
headers = {"User-Agent": get_user_agent()}
params = {"q": query, "kl": "us-en"}
url = "https://html.duckduckgo.com/html/"
@ -78,6 +174,7 @@ class WebSearchComponent(Component):
return DataFrame(
pd.DataFrame([{"title": "Error", "link": "", "snippet": "No results found", "content": ""}])
)
soup = BeautifulSoup(response.text, "html.parser")
results = []
@ -108,5 +205,122 @@ class WebSearchComponent(Component):
}
)
df_results = pd.DataFrame(results)
return DataFrame(df_results)
return DataFrame(pd.DataFrame(results))
def perform_news_search(self) -> DataFrame:
"""Perform Google News search."""
query = getattr(self, "query", "")
hl = getattr(self, "hl", "en-US") or "en-US"
gl = getattr(self, "gl", "US") or "US"
topic = getattr(self, "topic", None)
location = getattr(self, "location", None)
ceid = f"{gl}:{hl.split('-')[0]}"
# Build RSS URL based on parameters
if topic:
# Topic-based feed
base_url = f"https://news.google.com/rss/headlines/section/topic/{quote_plus(topic.upper())}"
params = f"?hl={hl}&gl={gl}&ceid={ceid}"
rss_url = base_url + params
elif location:
# Location-based feed
base_url = f"https://news.google.com/rss/headlines/section/geo/{quote_plus(location)}"
params = f"?hl={hl}&gl={gl}&ceid={ceid}"
rss_url = base_url + params
elif query:
# Keyword search feed
base_url = "https://news.google.com/rss/search?q="
query_encoded = quote_plus(query)
params = f"&hl={hl}&gl={gl}&ceid={ceid}"
rss_url = f"{base_url}{query_encoded}{params}"
else:
self.status = "No search query, topic, or location provided."
return DataFrame(
pd.DataFrame(
[{"title": "Error", "link": "", "published": "", "summary": "No search parameters provided"}]
)
)
try:
response = requests.get(rss_url, timeout=self.timeout)
response.raise_for_status()
soup = BeautifulSoup(response.content, "xml")
items = soup.find_all("item")
except requests.RequestException as e:
self.status = f"Failed to fetch news: {e}"
return DataFrame(pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": str(e)}]))
if not items:
self.status = "No news articles found."
return DataFrame(pd.DataFrame([{"title": "No articles found", "link": "", "published": "", "summary": ""}]))
articles = []
for item in items:
try:
title = self.clean_html(item.title.text if item.title else "")
link = item.link.text if item.link else ""
published = item.pubDate.text if item.pubDate else ""
summary = self.clean_html(item.description.text if item.description else "")
articles.append({"title": title, "link": link, "published": published, "summary": summary})
except (AttributeError, ValueError, TypeError) as e:
self.log(f"Error parsing article: {e!s}")
continue
return DataFrame(pd.DataFrame(articles))
def perform_rss_read(self) -> DataFrame:
"""Read RSS feed."""
rss_url = getattr(self, "query", "")
if not rss_url:
return DataFrame(
pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": "No RSS URL provided"}])
)
try:
response = requests.get(rss_url, timeout=self.timeout)
response.raise_for_status()
if not response.content.strip():
msg = "Empty response received"
raise ValueError(msg)
# Validate XML
try:
BeautifulSoup(response.content, "xml")
except Exception as e:
msg = f"Invalid XML response: {e}"
raise ValueError(msg) from e
soup = BeautifulSoup(response.content, "xml")
items = soup.find_all("item")
except (requests.RequestException, ValueError) as e:
self.status = f"Failed to fetch RSS: {e}"
return DataFrame(pd.DataFrame([{"title": "Error", "link": "", "published": "", "summary": str(e)}]))
articles = [
{
"title": item.title.text if item.title else "",
"link": item.link.text if item.link else "",
"published": item.pubDate.text if item.pubDate else "",
"summary": item.description.text if item.description else "",
}
for item in items
]
# Ensure DataFrame has correct columns even if empty
df_articles = pd.DataFrame(articles, columns=["title", "link", "published", "summary"])
self.log(f"Fetched {len(df_articles)} articles.")
return DataFrame(df_articles)
def perform_search(self) -> DataFrame:
"""Main search method that routes to appropriate search function based on mode."""
search_mode = getattr(self, "search_mode", "Web")
if search_mode == "Web":
return self.perform_web_search()
if search_mode == "News":
return self.perform_news_search()
if search_mode == "RSS":
return self.perform_rss_read()
# Fallback to web search
return self.perform_web_search()