diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-04-12 20:05:05 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-04-12 20:05:05 +0800 |
| commit | 5a8bb74e922f9ecebffbb03832b559f9dec21473 (patch) | |
| tree | e2dc0e2f8a9d1c0dc27fc79a823893ae650bdb51 /src | |
| parent | 3e204040c9a9fec810112933d0d81cecbd268943 (diff) | |
| download | rwebbrowser-5a8bb74e922f9ecebffbb03832b559f9dec21473.tar.gz rwebbrowser-5a8bb74e922f9ecebffbb03832b559f9dec21473.tar.xz | |
feat: add page title extraction, URL normalization, DuckDuckGo tracker stripping, and text wrapping support
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/browser.cc | 188 | ||||
| -rw-r--r-- | src/net/http_client.cc | 14 | ||||
| -rw-r--r-- | src/render/renderer.cc | 43 |
3 files changed, 208 insertions, 37 deletions
diff --git a/src/core/browser.cc b/src/core/browser.cc index 2968ab1..4c42bd0 100644 --- a/src/core/browser.cc +++ b/src/core/browser.cc @@ -25,6 +25,7 @@ #include <cstddef> #include <exception> #include <iostream> +#include <ranges> namespace ricochet::core { @@ -32,15 +33,57 @@ struct PageData { std::vector<std::string> lines; std::vector<std::string> links; + std::string title; }; namespace { + +std::string extract_title( const parser::DomNode& root_node ) +{ + std::vector<const parser::DomNode*> stack; + stack.push_back( &root_node ); + + while ( !stack.empty() ) { + const parser::DomNode* current = stack.back(); + stack.pop_back(); + + std::string base_tag = current->tag_name; + const std::size_t space_pos = base_tag.find( ' ' ); + if ( space_pos != std::string::npos ) { + base_tag = base_tag.substr( 0, space_pos ); + } + + for ( char& c : base_tag ) { + if ( c >= 'A' && c <= 'Z' ) { + c = static_cast<char>( c + 32 ); + } + } + + if ( base_tag == "title" ) { + std::string full_title; + for ( const auto& child : current->children ) { + if ( child.tag_name.empty() ) { + full_title += child.text_content; + } + } + if ( !full_title.empty() ) { + return full_title; + } + } + + for ( const auto& child : std::ranges::reverse_view( current->children ) ) { + stack.push_back( &child ); + } + } + return ""; +} + PageData load_page( std::string_view url ) { const net::HttpClient client; auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {} }; + return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .title = "Error" }; } const parser::HtmlLexer lexer; @@ -61,17 +104,40 @@ PageData load_page( std::string_view url ) lines.push_back( result.text.substr( start, end - start ) ); start = end + 1; } - return { .lines = lines, .links = result.links }; + const std::string title = extract_title( dom_root ); + return { .lines = lines, .links = result.links, .title = title }; } void draw_view( const tui::Terminal& terminal, const std::vector<std::string>& lines, std::size_t scroll_y, - std::string_view url ) + std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) + std::string_view title ) // NOLINT(bugprone-easily-swappable-parameters) { terminal.clear_screen(); auto [width, height] = terminal.get_size(); - const std::size_t usable_height = ( height > 1 ) ? ( height - 1 ) : 1; + const std::size_t usable_height = ( height > 2 ) ? ( height - 2 ) : 1; + + std::string clean_title( title ); + if ( clean_title.empty() ) { + const std::size_t start = url.find( "://" ); + const std::size_t host_start = ( start == std::string::npos ) ? 0 : start + 3; + const std::size_t host_end = url.find( '/', host_start ); + clean_title = std::string( url.substr( host_start, host_end - host_start ) ); + } + for ( char& c : clean_title ) { + if ( c == '\n' || c == '\r' ) { + c = ' '; + } + } + + std::string header = " PAGE: " + clean_title; + if ( header.size() < width ) { + header.append( width - header.size(), ' ' ); + } else if ( header.size() > width && width > 3 ) { + header = header.substr( 0, width - 3 ) + "..."; + } + std::cout << "\033[7m" << header << "\033[0m\r\n"; for ( std::size_t i = 0; i < usable_height; ++i ) { if ( scroll_y + i < lines.size() ) { @@ -80,7 +146,14 @@ void draw_view( const tui::Terminal& terminal, std::cout << "~\r\n"; } } - std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [H/L] History [q] Quit \033[0m"; + + std::string footer = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [H/L]History [q]Quit "; + if ( footer.size() < width ) { + footer.append( width - footer.size(), ' ' ); + } else if ( footer.size() > width && width > 3 ) { + footer = footer.substr( 0, width - 3 ) + "..."; + } + std::cout << "\033[7m" << footer << "\033[0m"; std::cout.flush(); } @@ -177,6 +250,52 @@ std::string get_link_input( const tui::Terminal& terminal ) return num_input; } +std::string normalize_url( const std::string& target, const std::string& current_url ) +{ + if ( target.starts_with( "/" ) ) { + if ( target.starts_with( "//" ) ) { + return "https:" + target; + } + const std::size_t slash_pos = current_url.find( '/', 8 ); + const std::string domain + = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos ); + return domain + target; + } + if ( !target.starts_with( "http" ) ) { + const std::size_t last_slash = current_url.rfind( '/' ); + if ( last_slash > 7 ) { + return current_url.substr( 0, last_slash + 1 ) + target; + } + return current_url + "/" + target; + } + return target; +} + +std::string strip_ddg_tracker( std::string target ) +{ + const std::size_t uddg_pos = target.find( "uddg=" ); + if ( uddg_pos != std::string::npos ) { + const std::size_t end_pos = target.find( '&', uddg_pos ); + const std::string encoded = target.substr( uddg_pos + 5, end_pos - ( uddg_pos + 5 ) ); + std::string decoded; + for ( std::size_t i = 0; i < encoded.length(); ++i ) { + if ( encoded[i] == '%' && i + 2 < encoded.length() ) { + const std::string hex = encoded.substr( i + 1, 2 ); + try { + decoded += static_cast<char>( std::stoi( hex, nullptr, 16 ) ); + } catch ( const std::exception& ) { + (void)0; + } + i += 2; + } else { + decoded += encoded[i]; + } + } + return decoded; + } + return target; +} + void handle_follow_link( const tui::Terminal& terminal, const std::vector<std::string>& links, std::string& current_url, @@ -189,23 +308,8 @@ void handle_follow_link( const tui::Terminal& terminal, if ( index > 0 && index <= links.size() ) { std::string target = links[index - 1]; - if ( target.starts_with( "/" ) ) { - if ( target.starts_with( "//" ) ) { - target = "https:" + target; // "//example.com" - } else { - const std::size_t slash_pos = current_url.find( '/', 8 ); // 跳过 https:// - const std::string domain - = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos ); - target = domain + target; // "/about" -> "https://gnu.org/about" - } - } else if ( !target.starts_with( "http" ) ) { - const std::size_t last_slash = current_url.rfind( '/' ); - if ( last_slash > 7 ) { - target = current_url.substr( 0, last_slash + 1 ) + target; - } else { - target = current_url + "/" + target; - } - } + target = normalize_url( target, current_url ); + target = strip_ddg_tracker( target ); current_url = target; navigate = true; @@ -276,6 +380,39 @@ InputAction process_key( char c, return InputAction::None; } +std::vector<std::string> wrap_lines( const std::vector<std::string>& original_lines, std::size_t wrap_width ) +{ + std::vector<std::string> wrapped_lines; + for ( const auto& line : original_lines ) { + std::string current_line; + std::size_t vis_count = 0; + bool in_ansi = false; + for ( const char c : line ) { + if ( c == '\x1b' ) { + in_ansi = true; + } + if ( in_ansi ) { + current_line += c; + if ( c == 'm' ) { + in_ansi = false; + } + } else { + if ( vis_count >= wrap_width ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + current_line.clear(); + vis_count = 0; + } + current_line += c; + vis_count++; + } + } + if ( !current_line.empty() || line.empty() ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + } + } + return wrapped_lines; +} + } // namespace int Browser::run( std::string_view initial_url ) @@ -293,11 +430,16 @@ int Browser::run( std::string_view initial_url ) std::cout.flush(); auto page_data = load_page( current_url ); + + auto [term_width, term_height] = terminal.get_size(); + const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; + page_data.lines = wrap_lines( page_data.lines, wrap_width ); + std::size_t scroll_y = 0; bool navigate = false; while ( !navigate ) { - draw_view( terminal, page_data.lines, scroll_y, current_url ); + draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); const char c = terminal.read_key(); const InputAction action = process_key( c, terminal, page_data, current_url, history, history_idx, scroll_y ); diff --git a/src/net/http_client.cc b/src/net/http_client.cc index 5cf6a30..143e067 100644 --- a/src/net/http_client.cc +++ b/src/net/http_client.cc @@ -39,7 +39,6 @@ struct ParsedUrl std::expected<ParsedUrl, std::string> parse_url( std::string_view url ) { std::string_view prefix = "http://"; - if ( url.starts_with( "https://" ) ) { prefix = "https://"; } else if ( url.starts_with( "http://" ) ) { @@ -49,17 +48,20 @@ std::expected<ParsedUrl, std::string> parse_url( std::string_view url ) } url.remove_prefix( prefix.length() ); - auto slash_pos = url.find( '/' ); + + const auto sep_pos = url.find_first_of( "/?#" ); ParsedUrl result; - if ( slash_pos == std::string_view::npos ) { + if ( sep_pos == std::string_view::npos ) { result.host = std::string( url ); result.path = "/"; } else { - result.host = std::string( url.substr( 0, slash_pos ) ); - result.path = std::string( url.substr( slash_pos ) ); + result.host = std::string( url.substr( 0, sep_pos ) ); + result.path = std::string( url.substr( sep_pos ) ); + if ( result.path[0] != '/' ) { + result.path.insert( 0, "/" ); + } } - return result; } diff --git a/src/render/renderer.cc b/src/render/renderer.cc index 2b690ca..ed622b4 100644 --- a/src/render/renderer.cc +++ b/src/render/renderer.cc @@ -18,19 +18,21 @@ #include "ricochet/render/renderer.hh" #include <array> +#include <cstdint> +#include <stdexcept> namespace ricochet::render { namespace { -std::string decode_entities( std::string text ) // NOLINT(readability-convert-member-functions-to-static) +std::string decode_entities( std::string text ) { - constexpr std::array<std::pair<std::string_view, std::string_view>, 24> entities - = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, - { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, { "£", "£" }, - { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, { "…", "…" }, - { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, { "″", "″" }, - { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; + constexpr std::array<std::pair<std::string_view, std::string_view>, 25> entities + = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, + { "'", "'" }, { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, + { "£", "£" }, { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, + { "…", "…" }, { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, + { "″", "″" }, { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; for ( const auto& [entity, replacement] : entities ) { std::size_t pos = 0; @@ -39,6 +41,31 @@ std::string decode_entities( std::string text ) // NOLINT(readability-convert-me pos += replacement.length(); } } + + std::size_t pos = 0; + while ( ( pos = text.find( "&#", pos ) ) != std::string::npos ) { + const std::size_t end = text.find( ';', pos ); + if ( end != std::string::npos && end - pos < 10 ) { + const std::string code = text.substr( pos + 2, end - pos - 2 ); + try { + std::uint32_t cp = 0; + if ( !code.empty() && ( code[0] == 'x' || code[0] == 'X' ) ) { + cp = std::stoul( code.substr( 1 ), nullptr, 16 ); + } else { + cp = std::stoul( code ); + } + if ( cp < 128 ) { + text.replace( pos, end - pos + 1, 1, static_cast<char>( cp ) ); + continue; + } + } catch ( const std::invalid_argument& ) { + (void)0; // Not a valid number, leave as-is + } catch ( const std::out_of_range& ) { + (void)0; // Value too large, leave as-is + } + } + pos++; + } return text; } @@ -107,7 +134,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) output += "\033[0m"; const std::string href = extract_href( node.tag_name ); if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { - links.push_back( href ); + links.push_back( decode_entities( href ) ); output += " \033[7m[" + std::to_string( links.size() ) + "]\033[0m"; // 打印数字标签! } } |
