pub mod builder;
pub mod config;
pub mod error;
pub mod extractors;
pub mod model;
pub mod providers;
pub use builder::{ImportResult, LlmProvider, RecipeImporter};
pub use error::ImportError;
pub use model::Recipe;
use log::debug;
use reqwest::header::{HeaderMap, USER_AGENT};
use scraper::Html;
use crate::extractors::{Extractor, ParsingContext};
pub async fn fetch_recipe(url: &str) -> Result<model::Recipe, ImportError> {
fetch_recipe_with_timeout(url, None).await
}
pub async fn fetch_recipe_with_timeout(
url: &str,
timeout: Option<std::time::Duration>,
) -> Result<model::Recipe, ImportError> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36".parse()?);
let mut client_builder = reqwest::Client::builder();
if let Some(timeout_duration) = timeout {
client_builder = client_builder.timeout(timeout_duration);
}
let client = client_builder.build()?;
let body = client
.get(url)
.headers(headers.clone())
.send()
.await?
.text()
.await?;
let context = ParsingContext {
url: url.to_string(),
document: Html::parse_document(&body),
texts: None,
};
let extractors_list: Vec<Box<dyn Extractor>> = vec![
Box::new(extractors::JsonLdExtractor),
Box::new(extractors::HtmlClassExtractor),
Box::new(extractors::PlainTextLlmExtractor),
];
for extractor in extractors_list {
match extractor.parse(&context) {
Ok(recipe) => {
debug!("{:#?}", recipe);
return Ok(recipe);
}
Err(e) => {
debug!("Extractor failed: {}", e);
}
}
}
Err(ImportError::NoExtractorMatched)
}
pub fn generate_frontmatter(metadata: &std::collections::HashMap<String, String>) -> String {
if metadata.is_empty() {
return String::new();
}
let mut frontmatter = String::from("---\n");
let mut keys: Vec<_> = metadata.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = metadata.get(key) {
if value.contains('\n') || value.contains('"') || value.contains(':') {
frontmatter.push_str(&format!("{}: \"{}\"\n", key, value.replace('"', "\\\"")));
} else {
frontmatter.push_str(&format!("{key}: {value}\n"));
}
}
}
frontmatter.push_str("---\n\n");
frontmatter
}
pub async fn convert_recipe_with_config(
recipe: &model::Recipe,
provider_name: Option<&str>,
api_key: Option<String>,
model: Option<String>,
) -> Result<String, ImportError> {
use crate::config::ProviderConfig;
use crate::providers::{AnthropicProvider, OpenAIProvider};
let name = provider_name.unwrap_or("anthropic");
let converter: Box<dyn crate::providers::LlmProvider> = match name {
"openai" => {
if let Some(key) = api_key {
let config = ProviderConfig {
enabled: true,
model: model.unwrap_or_else(|| "gpt-4".to_string()),
temperature: 0.7,
max_tokens: 4000,
api_key: Some(key),
base_url: None,
endpoint: None,
deployment_name: None,
api_version: None,
project_id: None,
};
Box::new(OpenAIProvider::new(&config).map_err(|e| {
ImportError::ConversionError(format!("Failed to create OpenAI provider: {}", e))
})?)
} else {
Box::new(OpenAIProvider::from_env().map_err(|e| {
ImportError::ConversionError(format!("Failed to create OpenAI provider: {}", e))
})?)
}
}
"anthropic" => {
let config = ProviderConfig {
enabled: true,
model: model.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()),
temperature: 0.7,
max_tokens: 4000,
api_key,
base_url: None,
endpoint: None,
deployment_name: None,
api_version: None,
project_id: None,
};
Box::new(AnthropicProvider::new(&config)
.map_err(|e| ImportError::ConversionError(format!("Failed to create Anthropic provider: {}. Make sure ANTHROPIC_API_KEY is set or pass api_key to builder.", e)))?)
}
_ => {
return Err(ImportError::ConversionError(format!(
"Provider '{}' requires a config.toml file or use convert_recipe_with_provider",
name
)));
}
};
let mut cooklang_recipe = converter
.convert(&recipe.content)
.await
.map_err(|e| ImportError::ConversionError(e.to_string()))?;
let frontmatter = generate_frontmatter(&recipe.metadata);
if !frontmatter.is_empty() {
cooklang_recipe = format!("{}{}", frontmatter, cooklang_recipe);
}
Ok(cooklang_recipe)
}
pub async fn convert_recipe_with_provider(
recipe: &model::Recipe,
provider_name: Option<&str>,
) -> Result<String, ImportError> {
use crate::config::AiConfig;
use crate::providers::{OpenAIProvider, ProviderFactory};
let config_result = AiConfig::load();
let converter: Box<dyn crate::providers::LlmProvider> = match config_result {
Ok(config) => {
let name = provider_name.unwrap_or(&config.default_provider);
let provider_config = config.providers.get(name).ok_or_else(|| {
ImportError::ConversionError(format!(
"Provider '{}' not found in configuration",
name
))
})?;
ProviderFactory::create(name, provider_config)
.map_err(|e| ImportError::ConversionError(e.to_string()))?
}
Err(_) => {
let name = provider_name.unwrap_or("openai");
match name {
"openai" => Box::new(OpenAIProvider::from_env().map_err(|e| {
ImportError::ConversionError(format!(
"Failed to create OpenAI provider from environment: {}",
e
))
})?),
"anthropic" => {
use crate::providers::AnthropicProvider;
let config = crate::config::ProviderConfig {
enabled: true,
model: "claude-sonnet-4-20250514".to_string(),
temperature: 0.7,
max_tokens: 4000,
api_key: None, base_url: None,
endpoint: None,
deployment_name: None,
api_version: None,
project_id: None,
};
Box::new(AnthropicProvider::new(&config)
.map_err(|e| ImportError::ConversionError(format!("Failed to create Anthropic provider from environment: {}. Make sure ANTHROPIC_API_KEY is set.", e)))?)
}
_ => {
return Err(ImportError::ConversionError(
format!(
"No configuration file found. Provider '{}' requires a config.toml file. \
For OpenAI and Anthropic, you can use environment variables (OPENAI_API_KEY or ANTHROPIC_API_KEY).",
name
)
));
}
}
}
};
let mut cooklang_recipe = converter
.convert(&recipe.content)
.await
.map_err(|e| ImportError::ConversionError(e.to_string()))?;
let frontmatter = generate_frontmatter(&recipe.metadata);
if !frontmatter.is_empty() {
cooklang_recipe = frontmatter + &cooklang_recipe;
}
Ok(cooklang_recipe)
}
pub async fn import_from_url(url: &str) -> Result<String, ImportError> {
match RecipeImporter::builder().url(url).build().await? {
builder::ImportResult::Cooklang(s) => Ok(s),
builder::ImportResult::Recipe(_) => unreachable!("Default mode is Cooklang"),
}
}
pub async fn extract_recipe_from_url(url: &str) -> Result<Recipe, ImportError> {
match RecipeImporter::builder()
.url(url)
.extract_only()
.build()
.await?
{
builder::ImportResult::Recipe(r) => Ok(r),
builder::ImportResult::Cooklang(_) => unreachable!("extract_only sets Recipe mode"),
}
}
pub async fn convert_text_to_cooklang(text: &str) -> Result<String, ImportError> {
match RecipeImporter::builder().text(text).build().await? {
builder::ImportResult::Cooklang(s) => Ok(s),
builder::ImportResult::Recipe(_) => unreachable!("Text + Cooklang mode"),
}
}