use std::future::Future;
use std::marker::PhantomData;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Map, Value};
use crate::common::interface::{ModuleNodeTrait, ModuleTrait, SyncBoxStream};
use crate::common::model::login_info::LoginInfo;
use crate::common::model::message::{TaskOutputEvent, TaskParserEvent};
use crate::common::model::request::RequestMethod;
use crate::common::model::{ModuleConfig, Request, Response};
use crate::common::state::State;
use crate::engine::engine::Engine;
use crate::errors::{Error, ErrorKind, Result};
use crate::common::config::ConfigProvider;
use crate::common::config::file::FileConfigProvider;
use crate::common::model::config::{
CacheConfig, ChannelConfig, Config, CrawlerConfig, DatabaseConfig, DownloadConfig,
};
use crate::common::model::message::TaskEvent;
use crate::queue::QueuedItem;
use tokio::sync::watch;
const FOLLOW_URL_KEY: &str = "__mocra_spider_follow_url";
#[async_trait]
pub trait Spider: Send + Sync + 'static {
type Item: Send + 'static;
fn name(&self) -> &str;
async fn start(&self, seeds: &mut Seeds);
async fn parse(&self, res: Response, cx: &mut Ctx<Self::Item>) -> Result<()>;
fn should_login(&self) -> bool {
false
}
fn version(&self) -> i32 {
1
}
}
#[derive(Default)]
pub struct Seeds {
reqs: Vec<Request>,
}
impl Seeds {
pub fn get(&mut self, url: impl AsRef<str>) -> &mut Request {
self.reqs.push(Request::new(url, RequestMethod::Get));
self.reqs.last_mut().expect("just pushed")
}
pub fn add(&mut self, req: Request) -> &mut Request {
self.reqs.push(req);
self.reqs.last_mut().expect("just pushed")
}
fn into_vec(self) -> Vec<Request> {
self.reqs
}
}
pub struct Ctx<Item> {
items: Vec<Item>,
follows: Vec<Request>,
}
impl<Item> Ctx<Item> {
fn new() -> Self {
Self {
items: Vec::new(),
follows: Vec::new(),
}
}
pub fn emit(&mut self, item: Item) {
self.items.push(item);
}
pub fn follow(&mut self, req: Request) {
self.follows.push(req);
}
pub fn follow_get(&mut self, url: impl AsRef<str>) {
self.follows.push(Request::new(url, RequestMethod::Get));
}
}
#[async_trait]
pub trait DataSink<Item>: Send + Sync {
async fn write(&self, item: Item) -> Result<()>;
}
pub struct ClosureSink<Item, F> {
f: F,
_p: PhantomData<fn(Item)>,
}
#[async_trait]
impl<Item, Fut, F> DataSink<Item> for ClosureSink<Item, F>
where
Item: Send + 'static,
F: Fn(Item) -> Fut + Send + Sync,
Fut: Future<Output = ()> + Send,
{
async fn write(&self, item: Item) -> Result<()> {
(self.f)(item).await;
Ok(())
}
}
pub fn on_item<Item, Fut, F>(f: F) -> ClosureSink<Item, F>
where
Item: Send + 'static,
F: Fn(Item) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
ClosureSink { f, _p: PhantomData }
}
pub struct ChannelSink<Item> {
tx: tokio::sync::mpsc::Sender<Item>,
}
impl<Item> ChannelSink<Item> {
pub fn new(tx: tokio::sync::mpsc::Sender<Item>) -> Self {
Self { tx }
}
}
#[async_trait]
impl<Item: Send + 'static> DataSink<Item> for ChannelSink<Item> {
async fn write(&self, item: Item) -> Result<()> {
self.tx.send(item).await.map_err(|_| {
Error::new(
ErrorKind::Service,
Some("ChannelSink: receiver dropped".to_string()),
)
})?;
Ok(())
}
}
struct SpiderModule<S: Spider> {
spider: Arc<S>,
sink: Arc<dyn DataSink<S::Item>>,
}
#[async_trait]
impl<S: Spider> ModuleTrait for SpiderModule<S> {
fn name(&self) -> String {
self.spider.name().to_string()
}
fn version(&self) -> i32 {
self.spider.version()
}
fn should_login(&self) -> bool {
self.spider.should_login()
}
fn default_arc() -> Arc<dyn ModuleTrait>
where
Self: Sized,
{
panic!("SpiderModule 需要一个 spider 实例,请使用 Mocra::builder().spider(..)");
}
async fn add_step(&self) -> Vec<Arc<dyn ModuleNodeTrait>> {
vec![Arc::new(SpiderNode {
spider: self.spider.clone(),
sink: self.sink.clone(),
})]
}
}
struct SpiderNode<S: Spider> {
spider: Arc<S>,
sink: Arc<dyn DataSink<S::Item>>,
}
#[async_trait]
impl<S: Spider> ModuleNodeTrait for SpiderNode<S> {
async fn generate(
&self,
_config: Arc<ModuleConfig>,
params: Map<String, Value>,
_login_info: Option<LoginInfo>,
) -> Result<SyncBoxStream<'static, Request>> {
if let Some(url) = params.get(FOLLOW_URL_KEY).and_then(|v| v.as_str()) {
let req = Request::new(url, RequestMethod::Get);
return Ok(Box::pin(futures::stream::iter(vec![req])));
}
let mut seeds = Seeds::default();
self.spider.start(&mut seeds).await;
Ok(Box::pin(futures::stream::iter(seeds.into_vec())))
}
async fn parser(
&self,
response: Response,
_config: Option<Arc<ModuleConfig>>,
) -> Result<TaskOutputEvent> {
let mut cx = Ctx::new();
self.spider.parse(response.clone(), &mut cx).await?;
for item in cx.items {
self.sink.write(item).await?;
}
let mut out = TaskOutputEvent::default();
for req in cx.follows {
let task = TaskParserEvent::from(&response).add_meta(FOLLOW_URL_KEY, req.url);
out = out.with_task(task);
}
Ok(out)
}
fn stable_node_key(&self) -> &'static str {
"spider"
}
}
pub struct Mocra;
impl Mocra {
pub fn builder() -> MocraBuilder {
MocraBuilder::default()
}
}
#[cfg(feature = "cluster-embedded")]
#[derive(Debug, Clone)]
pub struct ClusterConfig {
pub node_id: u64,
pub http_addr: String,
pub data_dir: String,
pub seeds: Vec<String>,
pub raft_tuning: Option<mocra_cluster::RaftTuning>,
}
#[cfg(feature = "cluster-embedded")]
impl ClusterConfig {
pub fn bootstrap(
node_id: u64,
http_addr: impl Into<String>,
data_dir: impl Into<String>,
) -> Self {
Self {
node_id,
http_addr: http_addr.into(),
data_dir: data_dir.into(),
seeds: Vec::new(),
raft_tuning: None,
}
}
pub fn join(
node_id: u64,
http_addr: impl Into<String>,
data_dir: impl Into<String>,
seed: impl Into<String>,
) -> Self {
Self {
node_id,
http_addr: http_addr.into(),
data_dir: data_dir.into(),
seeds: vec![seed.into()],
raft_tuning: None,
}
}
pub fn from_env() -> std::result::Result<Self, String> {
Self::from_vars(
std::env::var("MOCRA_NODE_ID").ok(),
std::env::var("MOCRA_HTTP_ADDR").ok(),
std::env::var("MOCRA_DATA_DIR").ok(),
std::env::var("MOCRA_SEEDS").ok(),
)
}
fn from_vars(
node_id: Option<String>,
http_addr: Option<String>,
data_dir: Option<String>,
seeds: Option<String>,
) -> std::result::Result<Self, String> {
let node_id = node_id
.ok_or("MOCRA_NODE_ID not set")?
.trim()
.parse::<u64>()
.map_err(|e| format!("MOCRA_NODE_ID invalid: {e}"))?;
let http_addr = http_addr
.filter(|s| !s.trim().is_empty())
.ok_or("MOCRA_HTTP_ADDR not set")?
.trim()
.to_string();
let data_dir = data_dir
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("./mocra-data/node-{node_id}"));
let seeds = seeds
.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Self {
node_id,
http_addr,
data_dir,
seeds,
raft_tuning: None,
})
}
pub fn with_raft_tuning(mut self, tuning: mocra_cluster::RaftTuning) -> Self {
self.raft_tuning = Some(tuning);
self
}
}
#[cfg(feature = "cluster-embedded")]
async fn start_embedded_cluster(
cluster: &ClusterConfig,
) -> Result<Arc<dyn crate::sync::CoordinationBackend>> {
use crate::sync::RaftCoordinationBackend;
use mocra_cluster::RaftControlPlane;
let svc_err =
|e: mocra_cluster::ControlError| Error::new(ErrorKind::Service, Some(e.to_string()));
let cp = match cluster.raft_tuning.clone() {
Some(tuning) => RaftControlPlane::start_cluster_node_with(
cluster.node_id,
&cluster.data_dir,
cluster.http_addr.clone(),
tuning,
)
.await
.map_err(svc_err)?,
None => RaftControlPlane::start_cluster_node(
cluster.node_id,
&cluster.data_dir,
cluster.http_addr.clone(),
)
.await
.map_err(svc_err)?,
};
if cluster.seeds.is_empty() {
let mut init = std::collections::BTreeMap::new();
init.insert(cluster.node_id, cluster.http_addr.clone());
cp.init_cluster(init).await.map_err(svc_err)?;
cp.wait_leader(std::time::Duration::from_secs(10))
.await
.map_err(svc_err)?;
log::info!(
"cluster: bootstrapped new Raft cluster as node {} @ {}",
cluster.node_id,
cluster.http_addr
);
} else {
RaftControlPlane::join_cluster(&cluster.seeds[0], cluster.node_id, &cluster.http_addr)
.await
.map_err(svc_err)?;
log::info!(
"cluster: node {} joined via seed {}",
cluster.node_id,
cluster.seeds[0]
);
}
Ok(Arc::new(RaftCoordinationBackend::new(cp)))
}
#[derive(Default)]
pub struct MocraBuilder {
config_path: Option<String>,
modules: Vec<Arc<dyn ModuleTrait>>,
downloaders: Vec<Box<dyn crate::downloader::Downloader>>,
default_downloader: Option<Box<dyn crate::downloader::Downloader>>,
#[cfg(feature = "cluster-embedded")]
cluster: Option<ClusterConfig>,
#[cfg(feature = "dashboard")]
dashboard_port: Option<u16>,
}
impl MocraBuilder {
pub fn from_toml(mut self, path: impl Into<String>) -> Self {
self.config_path = Some(path.into());
self
}
pub fn spider<S, K>(mut self, spider: S, sink: K) -> Self
where
S: Spider,
K: DataSink<S::Item> + 'static,
{
let sink: Arc<dyn DataSink<S::Item>> = Arc::new(sink);
let module: Arc<dyn ModuleTrait> = Arc::new(SpiderModule {
spider: Arc::new(spider),
sink,
});
self.modules.push(module);
self
}
pub fn downloader<D: crate::downloader::Downloader>(mut self, downloader: D) -> Self {
self.downloaders.push(Box::new(downloader));
self
}
pub fn default_downloader<D: crate::downloader::Downloader>(mut self, downloader: D) -> Self {
self.default_downloader = Some(Box::new(downloader));
self
}
#[cfg(feature = "cluster-embedded")]
pub fn cluster(mut self, cluster: ClusterConfig) -> Self {
self.cluster = Some(cluster);
self
}
#[cfg(feature = "dashboard")]
pub fn dashboard(mut self, port: u16) -> Self {
self.dashboard_port = Some(port);
self
}
pub async fn run(self) -> Result<()> {
let standalone = self.config_path.is_none();
let provider: Box<dyn ConfigProvider> = match &self.config_path {
Some(path) => Box::new(FileConfigProvider::new(path)),
None => Box::new(StaticConfigProvider {
config: default_standalone_config("mocra"),
}),
};
let coordination: Option<Arc<dyn crate::sync::CoordinationBackend>> = {
#[cfg(feature = "cluster-embedded")]
{
match &self.cluster {
Some(cluster) => Some(start_embedded_cluster(cluster).await?),
None => None,
}
}
#[cfg(not(feature = "cluster-embedded"))]
{
None
}
};
let state = State::try_new_with_provider_and_coordination(provider, coordination)
.await
.map_err(|e| Error::new(ErrorKind::Service, Some(e.to_string())))?;
let state = Arc::new(state);
#[cfg(feature = "dashboard")]
if let Some(port) = self.dashboard_port {
let mut cfg = state.config.write().await;
cfg.api = Some(crate::common::model::config::Api {
port,
api_key: None,
rate_limit: None,
});
if standalone {
cfg.crawler.idle_stop_secs = None;
if cfg.logger.is_none() {
use crate::common::model::logger_config::{LogOutputConfig, LoggerConfig};
cfg.logger = Some(LoggerConfig {
enabled: Some(true),
level: Some("info".to_string()),
format: None,
include: None,
buffer: None,
flush_interval_ms: None,
outputs: vec![LogOutputConfig::Console],
prometheus: None,
});
}
}
drop(cfg);
log::info!("dashboard: admin/observability API on http://127.0.0.1:{port}");
}
let coordination = state.coordination.clone();
let should_seed = coordination.as_ref().map(|c| c.is_leader()).unwrap_or(true);
let engine = Engine::new(state, None).await?;
for downloader in self.downloaders {
engine.downloader_manager.register(downloader).await;
}
if let Some(default_downloader) = self.default_downloader {
engine
.downloader_manager
.set_default_downloader(default_downloader)
.await;
}
let spider_names: Vec<String> = self.modules.iter().map(|m| m.name()).collect();
for module in self.modules {
engine.register_module(module).await;
}
if standalone && should_seed {
let task_tx = engine.queue_manager.get_task_push_channel();
for name in spider_names {
let ev = TaskEvent {
account: "default".to_string(),
platform: "default".to_string(),
module: Some(vec![name]),
priority: Default::default(),
run_id: uuid::Uuid::now_v7(),
};
if let Err(e) = task_tx.send(QueuedItem::new(ev)).await {
log::warn!("failed to inject seed task: {e}");
}
}
}
engine.start().await;
if let Some(c) = &coordination {
c.shutdown().await;
}
Ok(())
}
}
fn default_standalone_config(name: &str) -> Config {
Config {
name: name.to_string(),
db: DatabaseConfig {
url: None,
database_schema: None,
pool_size: None,
tls: None,
},
download_config: DownloadConfig {
downloader_expire: 3600,
timeout: 30,
rate_limit: 0.0,
enable_session: false,
enable_locker: false,
enable_rate_limit: false,
cache_ttl: 60,
wss_timeout: 30,
pool_size: Some(100),
max_response_size: Some(10 * 1024 * 1024),
},
cache: CacheConfig {
ttl: 60,
redis: None,
compression_threshold: None,
enable_l1: None,
l1_ttl_secs: None,
l1_max_entries: None,
},
crawler: CrawlerConfig {
request_max_retries: 2,
task_max_errors: 100,
module_max_errors: 50,
module_locker_ttl: 5,
node_id: None,
task_concurrency: Some(32),
publish_concurrency: None,
parser_concurrency: None,
error_task_concurrency: None,
backpressure_retry_delay_ms: None,
dedup_ttl_secs: None,
idle_stop_secs: Some(30),
},
scheduler: None,
sync: None,
cookie: None,
channel_config: ChannelConfig {
blob_storage: None,
redis: None,
kafka: None,
nats: None,
compensator: None,
minid_time: 12,
capacity: 10000,
queue_codec: None,
batch_concurrency: None,
compression_threshold: None,
nack_max_retries: None,
nack_backoff_ms: None,
},
proxy: None,
api: None,
event_bus: None,
logger: None,
policy: None,
}
}
struct StaticConfigProvider {
config: Config,
}
#[async_trait]
impl ConfigProvider for StaticConfigProvider {
async fn load_config(&self) -> std::result::Result<Config, String> {
Ok(self.config.clone())
}
async fn watch(&self) -> std::result::Result<watch::Receiver<Config>, String> {
let (_tx, rx) = watch::channel(self.config.clone());
Ok(rx)
}
}
#[cfg(all(test, feature = "cluster-embedded"))]
mod cluster_config_tests {
use super::ClusterConfig;
#[test]
fn bootstrap_has_no_seeds_join_has_one() {
let boot = ClusterConfig::bootstrap(1, "127.0.0.1:7001", "./d1");
assert_eq!(boot.node_id, 1);
assert_eq!(boot.http_addr, "127.0.0.1:7001");
assert!(boot.seeds.is_empty(), "bootstrap 节点无种子(自举)");
let joined = ClusterConfig::join(2, "127.0.0.1:7002", "./d2", "127.0.0.1:7001");
assert_eq!(joined.node_id, 2);
assert_eq!(joined.seeds, vec!["127.0.0.1:7001".to_string()]);
}
#[test]
fn from_vars_parses_env_style_config() {
let c = ClusterConfig::from_vars(
Some("3".into()),
Some("127.0.0.1:7003".into()),
Some("/data/n3".into()),
Some("127.0.0.1:7001, 127.0.0.1:7002".into()),
)
.unwrap();
assert_eq!(c.node_id, 3);
assert_eq!(c.http_addr, "127.0.0.1:7003");
assert_eq!(c.data_dir, "/data/n3");
assert_eq!(
c.seeds,
vec!["127.0.0.1:7001".to_string(), "127.0.0.1:7002".to_string()]
);
let boot =
ClusterConfig::from_vars(Some("1".into()), Some("127.0.0.1:7001".into()), None, None)
.unwrap();
assert!(boot.seeds.is_empty());
assert_eq!(boot.data_dir, "./mocra-data/node-1");
assert!(ClusterConfig::from_vars(None, Some("a".into()), None, None).is_err());
assert!(ClusterConfig::from_vars(Some("x".into()), Some("a".into()), None, None).is_err());
assert!(ClusterConfig::from_vars(Some("1".into()), None, None, None).is_err());
}
}
#[cfg(all(test, feature = "dashboard"))]
mod dashboard_tests {
use super::*;
struct Noop;
#[async_trait]
impl Spider for Noop {
type Item = ();
fn name(&self) -> &str {
"noop"
}
async fn start(&self, _s: &mut Seeds) {}
async fn parse(&self, _r: Response, _c: &mut Ctx<Self::Item>) -> Result<()> {
Ok(())
}
}
#[tokio::test]
async fn dashboard_serves_observability_endpoints() {
let port = 17673u16;
let server = tokio::spawn(async move {
let _ = Mocra::builder()
.spider(Noop, on_item(|_: ()| async {}))
.dashboard(port)
.run()
.await;
});
let base = format!("http://127.0.0.1:{port}");
let client = reqwest::Client::new();
let mut ready = false;
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if let Ok(r) = client.get(format!("{base}/health")).send().await
&& r.status().is_success()
{
ready = true;
break;
}
}
assert!(ready, "dashboard /health did not come up");
let engine: serde_json::Value = client
.get(format!("{base}/observability/engine"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(engine["single_node"], serde_json::json!(true));
assert_eq!(engine["namespace"], serde_json::json!("mocra"));
assert!(engine["pending"]["total"].is_number());
let cluster: serde_json::Value = client
.get(format!("{base}/observability/cluster"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(cluster.is_null());
let sys = client
.get(format!("{base}/observability/system"))
.header("Origin", "http://localhost:3000")
.send()
.await
.unwrap();
assert!(sys.status().is_success());
assert!(
sys.headers().contains_key("access-control-allow-origin"),
"CORS header missing — 前端跨域会被拦"
);
let _sys_json: serde_json::Value = sys.json().await.unwrap();
let logs: serde_json::Value = client
.get(format!("{base}/observability/logs?limit=50"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(logs.is_array(), "logs 端点应返回数组");
let page = client.get(format!("{base}/")).send().await.unwrap();
assert!(page.status().is_success());
let html = page.text().await.unwrap();
assert!(
html.contains("mocra dashboard"),
"根路径应托管内置 dashboard 页面"
);
server.abort();
}
}
#[cfg(test)]
mod downloader_tests {
use super::{ChannelSink, Ctx, Mocra, Seeds, Spider};
use async_trait::async_trait;
use semver::Version;
use crate::common::model::download_config::DownloadConfig;
use crate::common::model::{Cookies, Request, Response};
use crate::downloader::Downloader;
use crate::errors::Result;
#[derive(Clone)]
struct FakeDownloader;
#[async_trait]
impl Downloader for FakeDownloader {
async fn set_config(&self, _id: &str, _config: DownloadConfig) {}
async fn set_limit(&self, _id: &str, _limit: f32) {}
fn name(&self) -> String {
"fake_downloader".to_string()
}
fn version(&self) -> Version {
Version::new(1, 0, 0)
}
async fn download(&self, request: Request) -> Result<Response> {
Ok(Response {
id: request.id,
platform: request.platform,
account: request.account,
module: request.module,
status_code: 299,
cookies: Cookies { cookies: vec![] },
content: b"FAKE_DOWNLOADER_BODY".to_vec(),
storage_path: None,
headers: vec![],
task_retry_times: request.task_retry_times,
metadata: request.meta,
download_middleware: request.download_middleware,
data_middleware: request.data_middleware,
task_finished: request.task_finished,
context: request.context,
run_id: request.run_id,
prefix_request: request.prefix_request,
request_hash: None,
priority: request.priority,
})
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
}
struct ProbeSpider;
#[async_trait]
impl Spider for ProbeSpider {
type Item = u16;
fn name(&self) -> &str {
"downloader_probe"
}
async fn start(&self, s: &mut Seeds) {
s.get("https://this-host-does-not-resolve.invalid/x");
}
async fn parse(&self, res: Response, cx: &mut Ctx<Self::Item>) -> Result<()> {
if res.text_lossy().contains("FAKE_DOWNLOADER_BODY") {
cx.emit(res.status_code);
}
Ok(())
}
}
#[tokio::test]
async fn custom_default_downloader_serves_requests() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<u16>(4);
let server = tokio::spawn(async move {
let _ = Mocra::builder()
.spider(ProbeSpider, ChannelSink::new(tx))
.default_downloader(FakeDownloader)
.run()
.await;
});
let got = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await;
server.abort();
assert_eq!(
got.ok().flatten(),
Some(299),
"自定义默认下载器应已服务该请求(状态 299 + fake body);若为 None 说明仍走了 reqwest"
);
}
}