mod config;
mod deser;
mod param;
mod state;
use std::path::PathBuf;
use mediawiki::api::Api;
use serde_json::Value;
use time::{
format_description::BorrowedFormatItem, macros::format_description, Duration, OffsetDateTime,
PrimitiveDateTime,
};
use config::Config;
use param::Param;
use state::State;
const CONFIG_FILENAME: &str = "config.toml";
const STATE_FILENAME: &str = "state.toml";
const TIME_FORMAT: &[BorrowedFormatItem] =
format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
type Error = Box<dyn std::error::Error + Send + Sync>;
type Result<T> = std::result::Result<T, Error>;
fn now() -> OffsetDateTime {
let time = OffsetDateTime::now_local();
if time.is_err() {
OffsetDateTime::now_utc()
} else {
time.unwrap()
}
}
fn load_config(param: &Param) -> Result<Config> {
let mut path = param.data_dir().to_path_buf();
path.push(CONFIG_FILENAME);
if param.verbose() {
println!("Loading configuration file from {:?}", path);
}
Ok(Config::load(&path)?)
}
fn get_state_path(param: &Param) -> PathBuf {
let mut path = param.data_dir().to_path_buf();
path.push(STATE_FILENAME);
path
}
fn load_state(param: &Param, config: &Config) -> Result<State> {
let path = get_state_path(param);
if param.verbose() {
println!("Loading state file from {:?}", path);
}
let exists = path.try_exists();
if exists.is_err() {
if param.verbose() {
println!("State file is not found, so creating new state");
}
return Ok(State::new(config.domains()));
}
if exists.unwrap() {
Ok(State::load(&path)?)
} else {
if param.verbose() {
println!("State file may be broken symlink, so creating new state");
}
return Ok(State::new(config.domains()));
}
}
fn save_state(param: &Param, state: &State) -> Result<()> {
let path = get_state_path(param);
if param.dry_run() {
if param.verbose() {
println!("Dry run: Saving state file to: {:?}", path);
}
return Ok(());
}
if param.verbose() {
println!("Saving state file to: {:?}", path);
}
Ok(state.save(&path)?)
}
async fn connect(domain: &str) -> Option<Api> {
let url = format!("https://{domain}/w/api.php");
let api = Api::new(&url).await;
if let Err(err) = api {
eprintln!("{domain}: Error: Failed to connect to API: {err}");
None
} else {
Some(api.unwrap())
}
}
async fn login(param: &Param, config: &Config, domain: &str, api: &Api) -> bool {
let api_params = api.params_into(&[("action", "query"), ("meta", "tokens"), ("type", "login")]);
let res = api.post_query_api_json(&api_params).await;
if let Err(err) = res {
eprintln!("{domain}: Error: Failed to retrieve login token: {err}");
return false;
}
let res = res.unwrap();
if let Some(err) = res.get("error") {
eprintln!("{domain}: Error: Failed to retrieve login token: {err}");
return false;
}
let token = res["query"]["tokens"]["logintoken"].as_str().unwrap();
if param.verbose() {
println!("{domain}: Login token: {token}");
}
let api_params = api.params_into(&[
("action", "login"),
("lgname", config.username()),
("lgpassword", config.password()),
("lgtoken", token),
]);
match api.post_query_api_json(&api_params).await {
Ok(res) => {
if let Some(res) = res.get("login") {
if let Some(res) = res.get("result") {
if res == "WrongToken" {
eprintln!("{domain}: Error: Failed to login: {res}");
return false;
}
}
}
if param.verbose() {
println!("{domain}: Logged in");
}
true
}
Err(err) => {
eprintln!("{domain}: Error: Failed to login: {err}");
false
}
}
}
async fn request_token(domain: &str, api: &Api) -> Option<String> {
let api_params = api.params_into(&[("action", "query"), ("meta", "tokens")]);
let token = match api.post_query_api_json(&api_params).await {
Ok(res) => {
if let Some(err) = res.get("error") {
eprintln!("{domain}: Error: Failed to retrieve CSRF token: {err}");
return None;
}
res["query"]["tokens"]["csrftoken"]
.as_str()
.unwrap()
.to_string()
}
Err(err) => {
eprintln!("{domain}: Error: Failed to retrieve CSRF token: {err}");
return None;
}
};
Some(token)
}
async fn delete_pages(
now: OffsetDateTime,
param: &Param,
config: &Config,
domain: &str,
api: &Api,
token: &str,
) -> bool {
let delete_duration = Duration::DAY * config.keep_days();
let api_params = api.params_into(&[
("action", "query"),
("prop", "linkshere"),
("titles", config.delete_title()),
("lhlimit", &5000.to_string()),
]);
let res = api.post_query_api_json(&api_params).await;
if let Err(err) = res {
eprintln!("Error: Failed to query: {err}");
return false;
}
let res = res.unwrap();
let pages = &res["query"]["pages"]["-1"].get("linkshere");
if pages.is_none() {
eprintln!(
"{domain}: Error: Failed to retrieve backlinks from \"{}\"",
config.delete_title()
);
return false;
}
let pages = pages.unwrap();
let mut deleted = false;
if let Value::Array(pages) = pages {
for page in pages {
if param.verbose() {
println!("{domain}: title = {}", page["title"]);
}
let api_params = api.params_into(&[
("action", "query"),
("generator", "search"),
("gsrsearch", page["title"].as_str().unwrap()),
("gsrwhat", "title"),
("gsrprop", "timestamp"),
("prop", "extracts"),
("explaintext", "True"),
]);
let res = api.post_query_api_json(&api_params).await;
if let Err(err) = res {
eprintln!(
"{domain}: Error: Failed to query title \"{}\": {err}",
page["title"]
);
continue;
}
let res = res.unwrap();
if let Value::Object(pages) = &res["query"]["pages"] {
for info in pages.values() {
if info["title"] != page["title"] {
continue;
}
if param.verbose() {
println!(
"{domain}: title = {}, timestamp = {}, content={}",
info["title"], info["timestamp"], info["extract"]
);
}
let content = info["extract"].as_str().unwrap().trim();
let timestamp =
PrimitiveDateTime::parse(info["timestamp"].as_str().unwrap(), &TIME_FORMAT)
.unwrap();
let timestamp = OffsetDateTime::new_utc(timestamp.date(), timestamp.time());
if content != config.delete_title() || now - timestamp < delete_duration {
break;
}
if param.dry_run() {
if param.verbose() {
println!("{domain}: Dry run: Delete page \"{}\"", info["title"]);
}
deleted = true;
break;
}
let api_params = api.params_into(&[
("action", "delete"),
("title", info["title"].as_str().unwrap()),
("reason", config.delete_title()),
("deletetalk", "True"),
("token", token),
]);
match api.post_query_api_json(&api_params).await {
Ok(_res) => {
if param.verbose() {
println!("{domain}: Delete page \"{}\"", info["title"]);
}
deleted = true;
}
Err(err) => {
eprintln!(
"{domain}: Error: Failed to delete page \"{}\": {err}",
info["title"]
);
}
}
break;
}
}
}
}
deleted
}
async fn keep_alive(param: &Param, config: &Config, domain: &str, api: &Api, token: &str) -> bool {
if param.verbose() {
println!("{domain}: Processing keep alive");
}
let api_params = api.params_into(&[
("action", "edit"),
("title", config.keep_alive_title()),
("text", config.keep_alive_text()),
("summary", config.keep_alive_summary()),
("bot", "true"),
("token", &token),
]);
if param.dry_run() {
if param.verbose() {
println!(
"{domain}: Dry run: Edited keep alive page \"{}\"",
config.keep_alive_title()
);
}
return true;
}
match api.post_query_api_json(&api_params).await {
Ok(res) => {
if let Some(err) = res.get("error") {
eprintln!(
"{domain}: Error: Failed to edit keep alive page \"{}\": {err}",
config.keep_alive_title()
);
return false;
}
if param.verbose() {
println!(
"{domain}: Edited keep alive page \"{}\"",
config.keep_alive_title()
);
}
return true;
}
Err(err) => {
eprintln!(
"{domain}: Error: Failed to edit keep alive page \"{}\": {err}",
config.keep_alive_title()
);
return false;
}
}
}
async fn logout(param: &Param, domain: &str, api: &Api, token: &str) {
let api_params = api.params_into(&[("action", "logout"), ("token", token)]);
match api.post_query_api_json(&api_params).await {
Ok(res) => {
if let Some(err) = res.get("error") {
if param.verbose() {
eprintln!("{domain}: Warning: Failed to logout: {err}");
}
}
if param.verbose() {
println!("{domain}: Logged out");
}
}
Err(err) => {
if param.verbose() {
eprintln!("{domain}: Warning: Failed to logout: {err}");
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let now = now();
let param = Param::new();
if param.verbose() {
println!("Start time: {now}");
}
let config = load_config(¶m)?;
let mut state = load_state(¶m, &config)?;
let prev_state = state.clone();
for domain in config.domains() {
if param.verbose() {
println!("{domain}: Start processing");
}
let instance = state.instance(domain);
let instance = if instance.is_none() {
if param.verbose() {
println!("{domain}: New domain");
}
state.new_instance(domain).unwrap()
} else {
instance.unwrap()
};
let api = connect(domain).await;
if api.is_none() {
continue;
}
let api = api.unwrap();
if !login(¶m, &config, domain, &api).await {
continue;
}
let token = request_token(domain, &api).await;
if token.is_none() {
continue;
}
let token = token.unwrap();
if delete_pages(now, ¶m, &config, domain, &api, &token).await {
instance.update_last_edit_time(now);
}
let keep_alive_duration = Duration::DAY * config.keep_alive_days();
if now - instance.last_edit_time() >= keep_alive_duration {
if keep_alive(¶m, &config, domain, &api, &token).await {
instance.update_last_edit_time(now);
}
}
logout(¶m, domain, &api, &token).await;
}
if state != prev_state {
save_state(¶m, &state)?;
}
Ok(())
}