diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bddbe11..827fd27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,7 +398,7 @@ jobs: - name: Run tests with coverage working-directory: crates/feedparser-rs-node - run: npm test -- --coverage + run: npm run test:coverage continue-on-error: true - name: Upload Node.js coverage to codecov diff --git a/crates/feedparser-rs-core/src/http/client.rs b/crates/feedparser-rs-core/src/http/client.rs index b77b814..026c790 100644 --- a/crates/feedparser-rs-core/src/http/client.rs +++ b/crates/feedparser-rs-core/src/http/client.rs @@ -51,9 +51,19 @@ impl FeedHttpClient { } /// Sets a custom User-Agent header + /// + /// # Security + /// + /// User-Agent is truncated to 512 bytes to prevent header injection attacks. #[must_use] pub fn with_user_agent(mut self, agent: String) -> Self { - self.user_agent = agent; + // Truncate to 512 bytes to prevent header injection + const MAX_USER_AGENT_LEN: usize = 512; + self.user_agent = if agent.len() > MAX_USER_AGENT_LEN { + agent.chars().take(MAX_USER_AGENT_LEN).collect() + } else { + agent + }; self } @@ -125,16 +135,30 @@ impl FeedHttpClient { HeaderValue::from_static("gzip, deflate, br"), ); - // Conditional GET headers + // Conditional GET headers with length validation if let Some(etag_val) = etag { - Self::insert_header(&mut headers, IF_NONE_MATCH, etag_val, "ETag")?; + // Truncate ETag to 1KB to prevent oversized headers + const MAX_ETAG_LEN: usize = 1024; + let sanitized_etag = if etag_val.len() > MAX_ETAG_LEN { + &etag_val[..MAX_ETAG_LEN] + } else { + etag_val + }; + Self::insert_header(&mut headers, IF_NONE_MATCH, sanitized_etag, "ETag")?; } if let Some(modified_val) = modified { + // Truncate Last-Modified to 64 bytes (RFC 822 dates are ~30 bytes) + const MAX_MODIFIED_LEN: usize = 64; + let sanitized_modified = if modified_val.len() > MAX_MODIFIED_LEN { + &modified_val[..MAX_MODIFIED_LEN] + } else { + modified_val + }; Self::insert_header( &mut headers, IF_MODIFIED_SINCE, - modified_val, + sanitized_modified, "Last-Modified", )?; } @@ -161,8 +185,8 @@ impl FeedHttpClient { let status = response.status().as_u16(); let url = response.url().to_string(); - // Convert headers to HashMap - let mut headers_map = HashMap::new(); + // Convert headers to HashMap with pre-allocated capacity + let mut headers_map = HashMap::with_capacity(response.headers().len()); for (name, value) in response.headers() { if let Ok(val_str) = value.to_str() { headers_map.insert(name.to_string(), val_str.to_string()); diff --git a/crates/feedparser-rs-core/src/parser/rss.rs b/crates/feedparser-rs-core/src/parser/rss.rs index 7b024ef..1f9e73e 100644 --- a/crates/feedparser-rs-core/src/parser/rss.rs +++ b/crates/feedparser-rs-core/src/parser/rss.rs @@ -7,7 +7,8 @@ use crate::{ types::{ Enclosure, Entry, FeedVersion, Image, ItunesCategory, ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, Link, MediaContent, MediaThumbnail, ParsedFeed, PodcastFunding, PodcastMeta, - Source, Tag, TextConstruct, TextType, parse_duration, parse_explicit, + PodcastPerson, PodcastTranscript, Source, Tag, TextConstruct, TextType, parse_duration, + parse_explicit, }, util::parse_date, }; @@ -17,6 +18,26 @@ use super::common::{ EVENT_BUFFER_CAPACITY, FromAttributes, LimitedCollectionExt, init_feed, read_text, skip_element, }; +/// Limits string to maximum length by character count +/// +/// Uses efficient byte-length check before expensive char iteration. +/// Prevents oversized attribute/text values that could cause memory issues. +/// +/// # Examples +/// +/// ```ignore +/// let limited = limit_string("hello world", 5); // "hello" +/// let short = limit_string("hi", 100); // "hi" +/// ``` +#[inline] +fn limit_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + s.chars().take(max_len).collect() + } +} + /// Parse RSS 2.0 feed from raw bytes /// /// Parses an RSS 2.0 feed in tolerant mode, setting the bozo flag @@ -200,23 +221,85 @@ fn parse_channel( } true } else if is_itunes_tag(tag, b"category") { - // Parse category inline to avoid borrow conflicts + // Parse category with potential subcategory let mut category_text = String::new(); for attr in e.attributes().flatten() { if attr.key.as_ref() == b"text" && let Ok(value) = attr.unescape_value() { category_text = - value.chars().take(limits.max_attribute_length).collect(); + limit_string(&value, limits.max_attribute_length); + } + } + + // Parse potential nested subcategory + // We need to read until we find the closing tag for the parent category + let mut subcategory_text = None; + let mut nesting = 0; // Track category nesting level + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(sub_e)) => { + if is_itunes_tag(sub_e.name().as_ref(), b"category") { + nesting += 1; + if nesting == 1 { + // First nested category - this is the subcategory + for attr in sub_e.attributes().flatten() { + if attr.key.as_ref() == b"text" + && let Ok(value) = attr.unescape_value() + { + subcategory_text = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + break; + } + } + } + } + } + Ok(Event::Empty(sub_e)) => { + if is_itunes_tag(sub_e.name().as_ref(), b"category") + && subcategory_text.is_none() + { + // Self-closing nested category + for attr in sub_e.attributes().flatten() { + if attr.key.as_ref() == b"text" + && let Ok(value) = attr.unescape_value() + { + subcategory_text = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + break; + } + } + } + } + Ok(Event::End(end_e)) => { + if is_itunes_tag(end_e.name().as_ref(), b"category") { + if nesting == 0 { + // End of the parent category element + break; + } + nesting -= 1; + } + } + Ok(Event::Eof) | Err(_) => break, + _ => {} } + buf.clear(); } + let itunes = feed.feed.itunes.get_or_insert_with(ItunesFeedMeta::default); itunes.categories.push(ItunesCategory { text: category_text, - subcategory: None, + subcategory: subcategory_text, }); - skip_element(reader, &mut buf, limits, *depth)?; true } else if is_itunes_tag(tag, b"explicit") { let text = read_text(reader, &mut buf, limits)?; @@ -231,9 +314,8 @@ fn parse_channel( if attr.key.as_ref() == b"href" && let Ok(value) = attr.unescape_value() { - itunes.image = Some( - value.chars().take(limits.max_attribute_length).collect(), - ); + itunes.image = + Some(limit_string(&value, limits.max_attribute_length)); } } // NOTE: Don't call skip_element - itunes:image is typically self-closing @@ -268,7 +350,7 @@ fn parse_channel( if attr.key.as_ref() == b"url" && let Ok(value) = attr.unescape_value() { - url = value.chars().take(limits.max_attribute_length).collect(); + url = limit_string(&value, limits.max_attribute_length); } } let message_text = read_text(reader, &mut buf, limits)?; @@ -436,9 +518,8 @@ fn parse_item( if attr.key.as_ref() == b"href" && let Ok(value) = attr.unescape_value() { - itunes.image = Some( - value.chars().take(limits.max_attribute_length).collect(), - ); + itunes.image = + Some(limit_string(&value, limits.max_attribute_length)); } } // NOTE: Don't call skip_element - itunes:image is typically self-closing @@ -459,13 +540,127 @@ fn parse_item( itunes.episode_type = Some(text); true } else if tag.starts_with(b"podcast:transcript") { - // Podcast 2.0 transcript not stored in Entry for now - skip_element(reader, buf, limits, *depth)?; + // Parse Podcast 2.0 transcript inline + let mut url = String::new(); + let mut transcript_type = None; + let mut language = None; + let mut rel = None; + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"url" => { + if let Ok(value) = attr.unescape_value() { + url = value + .chars() + .take(limits.max_attribute_length) + .collect(); + } + } + b"type" => { + if let Ok(value) = attr.unescape_value() { + transcript_type = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + b"language" => { + if let Ok(value) = attr.unescape_value() { + language = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + b"rel" => { + if let Ok(value) = attr.unescape_value() { + rel = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + _ => {} + } + } + if !url.is_empty() { + entry.podcast_transcripts.push(PodcastTranscript { + url, + transcript_type, + language, + rel, + }); + } + if !is_empty { + skip_element(reader, buf, limits, *depth)?; + } true } else if tag.starts_with(b"podcast:person") { - // Parse person inline to avoid borrow conflicts - // Podcast 2.0 person not stored in Entry for now (no podcast field) - skip_element(reader, buf, limits, *depth)?; + // Parse Podcast 2.0 person inline + let mut role = None; + let mut group = None; + let mut img = None; + let mut href = None; + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"role" => { + if let Ok(value) = attr.unescape_value() { + role = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + b"group" => { + if let Ok(value) = attr.unescape_value() { + group = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + b"img" => { + if let Ok(value) = attr.unescape_value() { + img = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + b"href" => { + if let Ok(value) = attr.unescape_value() { + href = Some( + value + .chars() + .take(limits.max_attribute_length) + .collect(), + ); + } + } + _ => {} + } + } + let name = read_text(reader, buf, limits)?; + if !name.is_empty() { + entry.podcast_persons.push(PodcastPerson { + name, + role, + group, + img, + href, + }); + } true } else if let Some(dc_element) = is_dc_tag(tag) { // Dublin Core namespace diff --git a/crates/feedparser-rs-core/src/types/entry.rs b/crates/feedparser-rs-core/src/types/entry.rs index fc59275..7831d04 100644 --- a/crates/feedparser-rs-core/src/types/entry.rs +++ b/crates/feedparser-rs-core/src/types/entry.rs @@ -2,7 +2,7 @@ use super::{ common::{ Content, Enclosure, Link, MediaContent, MediaThumbnail, Person, Source, Tag, TextConstruct, }, - podcast::ItunesEntryMeta, + podcast::{ItunesEntryMeta, PodcastPerson, PodcastTranscript}, }; use chrono::{DateTime, Utc}; @@ -67,6 +67,10 @@ pub struct Entry { pub media_thumbnails: Vec, /// Media RSS content items pub media_content: Vec, + /// Podcast 2.0 transcripts for this episode + pub podcast_transcripts: Vec, + /// Podcast 2.0 persons for this episode (hosts, guests, etc.) + pub podcast_persons: Vec, } impl Entry { @@ -78,6 +82,8 @@ impl Entry { /// - 1 author /// - 2-3 tags /// - 0-1 enclosures + /// - 2 podcast transcripts (typical for podcasts with multiple languages) + /// - 4 podcast persons (host, co-hosts, guests) /// /// # Examples /// @@ -98,6 +104,8 @@ impl Entry { dc_subject: Vec::with_capacity(2), media_thumbnails: Vec::with_capacity(1), media_content: Vec::with_capacity(1), + podcast_transcripts: Vec::with_capacity(2), + podcast_persons: Vec::with_capacity(4), ..Default::default() } } diff --git a/crates/feedparser-rs-node/Cargo.toml b/crates/feedparser-rs-node/Cargo.toml index 906580b..1e6f9c7 100644 --- a/crates/feedparser-rs-node/Cargo.toml +++ b/crates/feedparser-rs-node/Cargo.toml @@ -17,5 +17,9 @@ feedparser-rs-core = { path = "../feedparser-rs-core" } napi = { workspace = true, features = ["napi9", "error_anyhow"] } napi-derive = { workspace = true } +[features] +default = ["http"] +http = ["feedparser-rs-core/http"] + [build-dependencies] napi-build = "2.1" diff --git a/crates/feedparser-rs-node/package-lock.json b/crates/feedparser-rs-node/package-lock.json index 95d02f1..2b03795 100644 --- a/crates/feedparser-rs-node/package-lock.json +++ b/crates/feedparser-rs-node/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT OR Apache-2.0", "devDependencies": { - "@napi-rs/cli": "^3.5" + "@napi-rs/cli": "^3.5", + "c8": "^10.1.3" }, "engines": { "node": ">= 18" @@ -21,6 +22,16 @@ "feedparser-rs-win32-x64-msvc": "0.1.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -399,6 +410,105 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/cli": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.5.0.tgz", @@ -1548,6 +1658,17 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1559,6 +1680,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1592,6 +1720,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -1599,6 +1734,50 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -1632,6 +1811,120 @@ "typanion": "*" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1639,6 +1932,28 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1657,6 +1972,13 @@ } } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emnapi": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-1.7.1.tgz", @@ -1690,6 +2012,16 @@ "benchmarks" ] }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -1719,6 +2051,50 @@ "node_modules/feedparser-rs-win32-x64-msvc": { "optional": true }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -1732,6 +2108,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", @@ -1749,6 +2163,78 @@ "url": "https://opencollective.com/express" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1762,6 +2248,71 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1790,6 +2341,92 @@ ], "license": "MIT" }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1810,6 +2447,29 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1841,6 +2501,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -1857,6 +2563,58 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1882,6 +2640,37 @@ "dev": true, "license": "ISC" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -1899,6 +2688,183 @@ "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/crates/feedparser-rs-node/package.json b/crates/feedparser-rs-node/package.json index eac40ef..e309585 100644 --- a/crates/feedparser-rs-node/package.json +++ b/crates/feedparser-rs-node/package.json @@ -40,10 +40,12 @@ "build": "napi build --platform --release", "build:debug": "napi build --platform", "test": "node --test __test__/index.spec.mjs", + "test:coverage": "c8 --reporter=lcov --reporter=text --reports-dir=coverage node --test __test__/index.spec.mjs", "prepublishOnly": "napi prepublish -t npm" }, "devDependencies": { - "@napi-rs/cli": "^3.5" + "@napi-rs/cli": "^3.5", + "c8": "^10.1.3" }, "optionalDependencies": { "feedparser-rs-darwin-arm64": "0.1.0", diff --git a/crates/feedparser-rs-node/src/lib.rs b/crates/feedparser-rs-node/src/lib.rs index 36985bd..06a686e 100644 --- a/crates/feedparser-rs-node/src/lib.rs +++ b/crates/feedparser-rs-node/src/lib.rs @@ -7,8 +7,9 @@ use std::collections::HashMap; use feedparser_rs_core::{ self as core, Content as CoreContent, Enclosure as CoreEnclosure, Entry as CoreEntry, FeedMeta as CoreFeedMeta, Generator as CoreGenerator, Image as CoreImage, Link as CoreLink, - ParsedFeed as CoreParsedFeed, ParserLimits, Person as CorePerson, Source as CoreSource, - Tag as CoreTag, TextConstruct as CoreTextConstruct, TextType, + ParsedFeed as CoreParsedFeed, ParserLimits, Person as CorePerson, + PodcastPerson as CorePodcastPerson, PodcastTranscript as CorePodcastTranscript, + Source as CoreSource, Tag as CoreTag, TextConstruct as CoreTextConstruct, TextType, }; /// Default maximum feed size (100 MB) - prevents DoS attacks @@ -103,6 +104,114 @@ pub fn detect_format(source: Either) -> String { version.to_string() } +/// Parse feed from HTTP/HTTPS URL with conditional GET support +/// +/// Fetches the feed from the given URL and parses it. Supports conditional GET +/// using ETag and Last-Modified headers for bandwidth-efficient caching. +/// +/// # Arguments +/// +/// * `url` - HTTP or HTTPS URL to fetch +/// * `etag` - Optional ETag from previous fetch for conditional GET +/// * `modified` - Optional Last-Modified timestamp from previous fetch +/// * `user_agent` - Optional custom User-Agent header +/// +/// # Returns +/// +/// Parsed feed result with HTTP metadata fields populated: +/// - `status`: HTTP status code (200, 304, etc.) +/// - `href`: Final URL after redirects +/// - `etag`: ETag header value (for next request) +/// - `modified`: Last-Modified header value (for next request) +/// - `headers`: Full HTTP response headers +/// +/// On 304 Not Modified, returns a feed with empty entries but status=304. +/// +/// # Examples +/// +/// ```javascript +/// const feedparser = require('feedparser-rs'); +/// +/// // First fetch +/// const feed = await feedparser.parseUrl("https://example.com/feed.xml"); +/// console.log(feed.feed.title); +/// console.log(`ETag: ${feed.etag}`); +/// +/// // Subsequent fetch with caching +/// const feed2 = await feedparser.parseUrl( +/// "https://example.com/feed.xml", +/// feed.etag, +/// feed.modified +/// ); +/// +/// if (feed2.status === 304) { +/// console.log("Feed not modified, use cached version"); +/// } +/// ``` +#[cfg(feature = "http")] +#[napi] +pub fn parse_url( + url: String, + etag: Option, + modified: Option, + user_agent: Option, +) -> Result { + let parsed = core::parse_url( + &url, + etag.as_deref(), + modified.as_deref(), + user_agent.as_deref(), + ) + .map_err(|e| Error::from_reason(format!("HTTP error: {}", e)))?; + + Ok(ParsedFeed::from(parsed)) +} + +/// Parse feed from URL with custom resource limits +/// +/// Like `parseUrl` but allows specifying custom limits for DoS protection. +/// +/// # Examples +/// +/// ```javascript +/// const feedparser = require('feedparser-rs'); +/// +/// const feed = await feedparser.parseUrlWithOptions( +/// "https://example.com/feed.xml", +/// null, // etag +/// null, // modified +/// null, // user_agent +/// 10485760 // max_size: 10MB +/// ); +/// ``` +#[cfg(feature = "http")] +#[napi] +pub fn parse_url_with_options( + url: String, + etag: Option, + modified: Option, + user_agent: Option, + max_size: Option, +) -> Result { + let max_feed_size = max_size.map_or(DEFAULT_MAX_FEED_SIZE, |s| s as usize); + + let limits = ParserLimits { + max_feed_size_bytes: max_feed_size, + ..ParserLimits::default() + }; + + let parsed = core::parse_url_with_limits( + &url, + etag.as_deref(), + modified.as_deref(), + user_agent.as_deref(), + limits, + ) + .map_err(|e| Error::from_reason(format!("HTTP error: {}", e)))?; + + Ok(ParsedFeed::from(parsed)) +} + /// Parsed feed result /// /// This is analogous to Python feedparser's `FeedParserDict`. @@ -122,6 +231,17 @@ pub struct ParsedFeed { pub version: String, /// XML namespaces (prefix -> URI) pub namespaces: HashMap, + /// HTTP status code (if fetched from URL) + pub status: Option, + /// Final URL after redirects (if fetched from URL) + pub href: Option, + /// ETag header from HTTP response + pub etag: Option, + /// Last-Modified header from HTTP response + pub modified: Option, + /// HTTP response headers (if fetched from URL) + #[cfg(feature = "http")] + pub headers: Option>, } impl From for ParsedFeed { @@ -134,6 +254,12 @@ impl From for ParsedFeed { encoding: core.encoding, version: core.version.to_string(), namespaces: core.namespaces, + status: core.status.map(|s| s as u32), + href: core.href, + etag: core.etag, + modified: core.modified, + #[cfg(feature = "http")] + headers: core.headers, } } } @@ -269,33 +395,85 @@ pub struct Entry { pub comments: Option, /// Source feed reference pub source: Option, + /// Podcast transcripts + pub podcast_transcripts: Vec, + /// Podcast persons + pub podcast_persons: Vec, } impl From for Entry { fn from(core: CoreEntry) -> Self { + // Pre-allocate Vec capacity to avoid reallocations + let links_cap = core.links.len(); + let content_cap = core.content.len(); + let authors_cap = core.authors.len(); + let contributors_cap = core.contributors.len(); + let tags_cap = core.tags.len(); + let enclosures_cap = core.enclosures.len(); + let transcripts_cap = core.podcast_transcripts.len(); + let persons_cap = core.podcast_persons.len(); + Self { id: core.id, title: core.title, title_detail: core.title_detail.map(TextConstruct::from), link: core.link, - links: core.links.into_iter().map(Link::from).collect(), + links: { + let mut v = Vec::with_capacity(links_cap); + v.extend(core.links.into_iter().map(Link::from)); + v + }, summary: core.summary, summary_detail: core.summary_detail.map(TextConstruct::from), - content: core.content.into_iter().map(Content::from).collect(), + content: { + let mut v = Vec::with_capacity(content_cap); + v.extend(core.content.into_iter().map(Content::from)); + v + }, published: core.published.map(|dt| dt.timestamp_millis()), updated: core.updated.map(|dt| dt.timestamp_millis()), created: core.created.map(|dt| dt.timestamp_millis()), expired: core.expired.map(|dt| dt.timestamp_millis()), author: core.author, author_detail: core.author_detail.map(Person::from), - authors: core.authors.into_iter().map(Person::from).collect(), - contributors: core.contributors.into_iter().map(Person::from).collect(), + authors: { + let mut v = Vec::with_capacity(authors_cap); + v.extend(core.authors.into_iter().map(Person::from)); + v + }, + contributors: { + let mut v = Vec::with_capacity(contributors_cap); + v.extend(core.contributors.into_iter().map(Person::from)); + v + }, publisher: core.publisher, publisher_detail: core.publisher_detail.map(Person::from), - tags: core.tags.into_iter().map(Tag::from).collect(), - enclosures: core.enclosures.into_iter().map(Enclosure::from).collect(), + tags: { + let mut v = Vec::with_capacity(tags_cap); + v.extend(core.tags.into_iter().map(Tag::from)); + v + }, + enclosures: { + let mut v = Vec::with_capacity(enclosures_cap); + v.extend(core.enclosures.into_iter().map(Enclosure::from)); + v + }, comments: core.comments, source: core.source.map(Source::from), + podcast_transcripts: { + let mut v = Vec::with_capacity(transcripts_cap); + v.extend( + core.podcast_transcripts + .into_iter() + .map(PodcastTranscript::from), + ); + v + }, + podcast_persons: { + let mut v = Vec::with_capacity(persons_cap); + v.extend(core.podcast_persons.into_iter().map(PodcastPerson::from)); + v + }, } } } @@ -520,3 +698,55 @@ impl From for Source { } } } + +/// Podcast transcript metadata +#[napi(object)] +pub struct PodcastTranscript { + /// Transcript URL + pub url: String, + /// Transcript type (e.g., "text/plain", "application/srt") + #[napi(js_name = "type")] + pub transcript_type: Option, + /// Transcript language + pub language: Option, + /// Relationship type (e.g., "captions", "chapters") + pub rel: Option, +} + +impl From for PodcastTranscript { + fn from(core: CorePodcastTranscript) -> Self { + Self { + url: core.url, + transcript_type: core.transcript_type, + language: core.language, + rel: core.rel, + } + } +} + +/// Podcast person metadata +#[napi(object)] +pub struct PodcastPerson { + /// Person's name + pub name: String, + /// Person's role (e.g., "host", "guest") + pub role: Option, + /// Person's group (e.g., "cast", "crew") + pub group: Option, + /// Person's image URL + pub img: Option, + /// Person's URL/website + pub href: Option, +} + +impl From for PodcastPerson { + fn from(core: CorePodcastPerson) -> Self { + Self { + name: core.name, + role: core.role, + group: core.group, + img: core.img, + href: core.href, + } + } +} diff --git a/crates/feedparser-rs-py/Cargo.toml b/crates/feedparser-rs-py/Cargo.toml index 1d22fc5..0ad045d 100644 --- a/crates/feedparser-rs-py/Cargo.toml +++ b/crates/feedparser-rs-py/Cargo.toml @@ -18,3 +18,7 @@ crate-type = ["cdylib"] feedparser-rs-core = { path = "../feedparser-rs-core" } pyo3 = { workspace = true, features = ["extension-module", "chrono"] } chrono = { workspace = true, features = ["clock"] } + +[features] +default = ["http"] +http = ["feedparser-rs-core/http"] diff --git a/crates/feedparser-rs-py/src/lib.rs b/crates/feedparser-rs-py/src/lib.rs index b700be5..7d1dbd5 100644 --- a/crates/feedparser-rs-py/src/lib.rs +++ b/crates/feedparser-rs-py/src/lib.rs @@ -15,6 +15,10 @@ use types::PyParsedFeed; fn _feedparser_rs(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(parse, m)?)?; m.add_function(wrap_pyfunction!(parse_with_limits, m)?)?; + #[cfg(feature = "http")] + m.add_function(wrap_pyfunction!(parse_url, m)?)?; + #[cfg(feature = "http")] + m.add_function(wrap_pyfunction!(parse_url_with_limits, m)?)?; m.add_function(wrap_pyfunction!(detect_format, m)?)?; m.add_class::()?; m.add_class::()?; @@ -72,3 +76,92 @@ fn detect_format(source: &Bound<'_, PyAny>) -> PyResult { }; Ok(core::detect_format(&bytes).to_string()) } + +/// Parse feed from HTTP/HTTPS URL with conditional GET support +/// +/// Fetches the feed from the given URL and parses it. Supports conditional GET +/// using ETag and Last-Modified headers for bandwidth-efficient caching. +/// +/// # Arguments +/// +/// * `url` - HTTP or HTTPS URL to fetch +/// * `etag` - Optional ETag from previous fetch for conditional GET +/// * `modified` - Optional Last-Modified timestamp from previous fetch +/// * `user_agent` - Optional custom User-Agent header +/// +/// # Returns +/// +/// Returns a `FeedParserDict` with HTTP metadata fields populated: +/// - `status`: HTTP status code (200, 304, etc.) +/// - `href`: Final URL after redirects +/// - `etag`: ETag header value (for next request) +/// - `modified`: Last-Modified header value (for next request) +/// - `headers`: Full HTTP response headers +/// +/// On 304 Not Modified, returns a feed with empty entries but status=304. +/// +/// # Examples +/// +/// ```python +/// import feedparser_rs +/// +/// # First fetch +/// feed = feedparser_rs.parse_url("https://example.com/feed.xml") +/// print(feed.feed.title) +/// print(f"ETag: {feed.etag}") +/// +/// # Subsequent fetch with caching +/// feed2 = feedparser_rs.parse_url( +/// "https://example.com/feed.xml", +/// etag=feed.etag, +/// modified=feed.modified +/// ) +/// +/// if feed2.status == 304: +/// print("Feed not modified, use cached version") +/// ``` +#[cfg(feature = "http")] +#[pyfunction] +#[pyo3(signature = (url, etag=None, modified=None, user_agent=None))] +fn parse_url( + py: Python<'_>, + url: &str, + etag: Option<&str>, + modified: Option<&str>, + user_agent: Option<&str>, +) -> PyResult { + let parsed = core::parse_url(url, etag, modified, user_agent).map_err(convert_feed_error)?; + PyParsedFeed::from_core(py, parsed) +} + +/// Parse feed from URL with custom resource limits +/// +/// Like `parse_url` but allows specifying custom limits for DoS protection. +/// +/// # Examples +/// +/// ```python +/// import feedparser_rs +/// +/// limits = feedparser_rs.ParserLimits.strict() +/// feed = feedparser_rs.parse_url_with_limits( +/// "https://example.com/feed.xml", +/// limits=limits +/// ) +/// ``` +#[cfg(feature = "http")] +#[pyfunction] +#[pyo3(signature = (url, etag=None, modified=None, user_agent=None, limits=None))] +fn parse_url_with_limits( + py: Python<'_>, + url: &str, + etag: Option<&str>, + modified: Option<&str>, + user_agent: Option<&str>, + limits: Option<&PyParserLimits>, +) -> PyResult { + let parser_limits = limits.map(|l| l.to_core_limits()).unwrap_or_default(); + let parsed = core::parse_url_with_limits(url, etag, modified, user_agent, parser_limits) + .map_err(convert_feed_error)?; + PyParsedFeed::from_core(py, parsed) +} diff --git a/crates/feedparser-rs-py/src/types/entry.rs b/crates/feedparser-rs-py/src/types/entry.rs index 682e944..850a7d9 100644 --- a/crates/feedparser-rs-py/src/types/entry.rs +++ b/crates/feedparser-rs-py/src/types/entry.rs @@ -3,7 +3,7 @@ use pyo3::prelude::*; use super::common::{PyContent, PyEnclosure, PyLink, PyPerson, PySource, PyTag, PyTextConstruct}; use super::datetime::optional_datetime_to_struct_time; -use super::podcast::PyItunesEntryMeta; +use super::podcast::{PyItunesEntryMeta, PyPodcastPerson, PyPodcastTranscript}; #[pyclass(name = "Entry", module = "feedparser_rs")] #[derive(Clone)] @@ -196,6 +196,24 @@ impl PyEntry { .map(|i| PyItunesEntryMeta::from_core(i.clone())) } + #[getter] + fn podcast_transcripts(&self) -> Vec { + self.inner + .podcast_transcripts + .iter() + .map(|t| PyPodcastTranscript::from_core(t.clone())) + .collect() + } + + #[getter] + fn podcast_persons(&self) -> Vec { + self.inner + .podcast_persons + .iter() + .map(|p| PyPodcastPerson::from_core(p.clone())) + .collect() + } + fn __repr__(&self) -> String { format!( "Entry(title='{}', id='{}')", diff --git a/crates/feedparser-rs-py/src/types/parsed_feed.rs b/crates/feedparser-rs-py/src/types/parsed_feed.rs index 48d4047..1aa6119 100644 --- a/crates/feedparser-rs-py/src/types/parsed_feed.rs +++ b/crates/feedparser-rs-py/src/types/parsed_feed.rs @@ -14,6 +14,12 @@ pub struct PyParsedFeed { encoding: String, version: String, namespaces: Py, + status: Option, + href: Option, + etag: Option, + modified: Option, + #[cfg(feature = "http")] + headers: Option>, } impl PyParsedFeed { @@ -31,6 +37,17 @@ impl PyParsedFeed { namespaces.set_item(prefix, uri)?; } + #[cfg(feature = "http")] + let headers = if let Some(headers_map) = core.headers { + let headers_dict = PyDict::new(py); + for (key, value) in headers_map { + headers_dict.set_item(key, value)?; + } + Some(headers_dict.unbind()) + } else { + None + }; + Ok(Self { feed, entries: entries?, @@ -39,6 +56,12 @@ impl PyParsedFeed { encoding: core.encoding, version: core.version.to_string(), namespaces: namespaces.unbind(), + status: core.status, + href: core.href, + etag: core.etag, + modified: core.modified, + #[cfg(feature = "http")] + headers, }) } } @@ -80,6 +103,32 @@ impl PyParsedFeed { self.namespaces.clone_ref(py) } + #[getter] + fn status(&self) -> Option { + self.status + } + + #[getter] + fn href(&self) -> Option<&str> { + self.href.as_deref() + } + + #[getter] + fn etag(&self) -> Option<&str> { + self.etag.as_deref() + } + + #[getter] + fn modified(&self) -> Option<&str> { + self.modified.as_deref() + } + + #[cfg(feature = "http")] + #[getter] + fn headers(&self, py: Python<'_>) -> Option> { + self.headers.as_ref().map(|h| h.clone_ref(py)) + } + fn __repr__(&self) -> String { format!( "FeedParserDict(version='{}', bozo={}, entries={})",