| @@ -0,0 +1,22 @@ | |||
| [package] | |||
| name = "copyvios" | |||
| version = "0.1.0" | |||
| authors = ["Ben Kurtovic <ben@benkurtovic.com>"] | |||
| license = "MIT" | |||
| edition = "2021" | |||
| [dependencies] | |||
| askama = { version = "0.12", features = ["with-axum"] } | |||
| askama_axum = "0.3" | |||
| axum = "0.6" | |||
| mwapi = "0.6.0" | |||
| mwapi_responses = "0.4.2" | |||
| mwbot = "0.6.1" | |||
| parsoid = "0.8.0" | |||
| rand = "0.8.5" | |||
| thiserror = "1.0.56" | |||
| tokio = { version = "1.0", features = ["full"] } | |||
| tower = { version = "0.4", features = ["util"] } | |||
| tower-http = { version = "0.4", features = ["fs", "trace"] } | |||
| tracing = "0.1" | |||
| tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |||
| @@ -0,0 +1,70 @@ | |||
| use crate::{site::Site, Error, Result}; | |||
| use mwapi_responses::prelude::*; | |||
| use mwbot::{parsoid::WikinodeIterator, Bot, Page}; | |||
| use rand::seq::SliceRandom; | |||
| pub struct Background { | |||
| pub image_url: String, | |||
| pub source_url: String, | |||
| } | |||
| async fn get_potd_images(bot: &Bot) -> Result<Vec<Page>> { | |||
| let page = bot.page("User:The Earwig/POTD")?; | |||
| let html = page.html().await?.into_mutable(); | |||
| let mut images = Vec::new(); | |||
| for link in html.filter_links() { | |||
| let target = bot.page(&link.target())?; | |||
| if !target.is_file() { | |||
| continue; | |||
| } | |||
| images.push(target); | |||
| } | |||
| Ok(images) | |||
| } | |||
| #[query(prop = "imageinfo", iiprop = "url|size|canonicaltitle")] | |||
| pub(crate) struct InfoResponse {} | |||
| async fn get_background_from_page(bot: &Bot, image: &Page) -> Result<Background> { | |||
| let mut resp: InfoResponse = | |||
| mwapi_responses::query_api(&bot.api(), [("titles", image.title())]).await?; | |||
| let info = resp | |||
| .query | |||
| .pages | |||
| .pop() | |||
| .ok_or(Error::NoBackgroundError(format!( | |||
| "Background image not found: {}", | |||
| { image.title() } | |||
| ))); | |||
| tracing::info!("info: {:?}", info); | |||
| // data = site.api_query( | |||
| // action="query", prop="imageinfo", iiprop="url|size|canonicaltitle", | |||
| // titles="File:" + filename) | |||
| // res = data["query"]["pages"].values()[0]["imageinfo"][0] | |||
| // name = res["canonicaltitle"][len("File:"):].replace(" ", "_") | |||
| // return name, res["url"], res["descriptionurl"], res["width"], res["height"] | |||
| let image_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/Crepuscular_rays_at_Sunset_near_Waterberg_Plateau.jpg/2560px-Crepuscular_rays_at_Sunset_near_Waterberg_Plateau.jpg"; | |||
| let source_url = "https://commons.wikimedia.org/wiki/File:Crepuscular_rays_at_Sunset_near_Waterberg_Plateau.jpg"; | |||
| Ok(Background { | |||
| image_url: String::from(image_url), | |||
| source_url: String::from(source_url), | |||
| }) | |||
| } | |||
| pub async fn get_background() -> Result<Background> { | |||
| let site = Site::new("wikimedia", "commons"); | |||
| let bot = site.bot().await.unwrap(); | |||
| let images = get_potd_images(&bot).await?; | |||
| let image = images.choose(&mut rand::thread_rng()); | |||
| let image = | |||
| image.ok_or_else(|| Error::BackgroundError(String::from("no POTD images found")))?; | |||
| tracing::info!("Background image: {:?}", image.title()); | |||
| get_background_from_page(&bot, image).await | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| use thiserror::Error as ThisError; | |||
| pub type Result<T, E = Error> = std::result::Result<T, E>; | |||
| #[non_exhaustive] | |||
| #[derive(ThisError, Debug)] | |||
| pub enum Error { | |||
| #[error("API error: {0}")] | |||
| ApiError(#[from] mwapi::Error), | |||
| #[error("Bot error: {0}")] | |||
| BotError(#[from] mwbot::Error), | |||
| #[error("Config error: {0}")] | |||
| ConfigError(#[from] mwbot::ConfigError), | |||
| #[error("Unable to find background image: {0}")] | |||
| BackgroundError(String), | |||
| // add nobackgroundserror, backgroundunavailableerror.... | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| mod background; | |||
| mod error; | |||
| mod site; | |||
| pub use error::{Error, Result}; | |||
| use crate::background::Background; | |||
| use askama::Template; | |||
| use axum::{routing::get, Router}; | |||
| use std::net::SocketAddr; | |||
| use tower_http::services::ServeDir; | |||
| use tracing_subscriber::filter::LevelFilter; | |||
| use tracing_subscriber::EnvFilter; | |||
| #[tokio::main] | |||
| async fn main() { | |||
| let filter = EnvFilter::builder() | |||
| .with_default_directive(LevelFilter::INFO.into()) | |||
| .from_env() | |||
| .expect("invalid RUST_LOG value"); | |||
| tracing_subscriber::fmt().with_env_filter(filter).init(); | |||
| let app = Router::new() | |||
| .route("/", get(index)) | |||
| .route("/api", get(api)) | |||
| .route("/language", get(language)) | |||
| .route("/settings", get(settings)) | |||
| .nest_service("/static", ServeDir::new("static")); | |||
| let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); | |||
| tracing::info!("listening on {}", addr); | |||
| axum::Server::bind(&addr) | |||
| .serve(app.into_make_service()) | |||
| .await | |||
| .unwrap(); | |||
| } | |||
| #[derive(Template)] | |||
| #[template(path = "index.html")] | |||
| struct IndexTemplate { | |||
| background: Background, | |||
| } | |||
| async fn index() -> IndexTemplate { | |||
| let background = background::get_background() | |||
| .await | |||
| .expect("failed to get background image"); // TODO: handle gracefully | |||
| IndexTemplate { background } | |||
| } | |||
| async fn api() -> &'static str { | |||
| "TODO" | |||
| } | |||
| async fn language() -> &'static str { | |||
| "TODO" | |||
| } | |||
| async fn settings() -> &'static str { | |||
| "TODO" | |||
| } | |||
| @@ -0,0 +1,37 @@ | |||
| use crate::Result; | |||
| use mwbot::Bot; | |||
| const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); | |||
| fn user_agent() -> String { | |||
| format!("EarwigCVDetector/{}", PKG_VERSION) | |||
| } | |||
| pub struct Site { | |||
| project: String, | |||
| lang: String, | |||
| } | |||
| impl Site { | |||
| pub fn new<S: ToString>(project: S, lang: S) -> Self { | |||
| Site { | |||
| project: project.to_string(), | |||
| lang: lang.to_string(), | |||
| } | |||
| } | |||
| fn domain(&self) -> String { | |||
| format!("{}.{}.org", self.lang, self.project) | |||
| } | |||
| pub async fn bot(&self) -> Result<Bot, mwbot::ConfigError> { | |||
| let domain = self.domain(); | |||
| Bot::builder( | |||
| format!("https://{}/w/api.php", domain), | |||
| format!("https://{}/api/rest_v1", domain), | |||
| ) | |||
| .set_user_agent(user_agent()) | |||
| .build() | |||
| .await | |||
| } | |||
| } | |||
| @@ -0,0 +1,222 @@ | |||
| html, body { | |||
| height: 100%; | |||
| margin: 0; | |||
| } | |||
| body { | |||
| line-height: 1.4; | |||
| display: flex; | |||
| flex-direction: column; | |||
| background-color: #eaecf0; | |||
| background-attachment: fixed; | |||
| background-size: cover; | |||
| font-family: -apple-system,'BlinkMacSystemFont','Segoe UI','Roboto','Lato','Helvetica','Arial',sans-serif; | |||
| font-size: calc(1em * 0.875); | |||
| color: #000; | |||
| } | |||
| #container-inner { | |||
| margin: 1em 1em 4em; | |||
| max-width: 100em; | |||
| border: 1px solid #c8ccd1; | |||
| background-color: #fff; | |||
| padding: 1em; | |||
| filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.25)); | |||
| } | |||
| #a-settings::before { | |||
| background-image: linear-gradient(transparent,transparent), url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 xmlns:xlink=%22http://www.w3.org/1999/xlink%22 width=%2220%22 height=%2220%22 viewBox=%220 0 20 20%22%3E%3Ctitle%3Esettings%3C/title%3E%3Cg transform=%22translate%2810 10%29%22%3E%3Cpath id=%22a%22 d=%22M1.5-10h-3l-1 6.5h5m0 7h-5l1 6.5h3%22/%3E%3Cuse transform=%22rotate%2845%29%22 xlink:href=%22%23a%22/%3E%3Cuse transform=%22rotate%2890%29%22 xlink:href=%22%23a%22/%3E%3Cuse transform=%22rotate%28135%29%22 xlink:href=%22%23a%22/%3E%3C/g%3E%3Cpath d=%22M10 2.5a7.5 7.5 0 000 15 7.5 7.5 0 000-15v4a3.5 3.5 0 010 7 3.5 3.5 0 010-7%22/%3E%3C/svg%3E"); | |||
| background-repeat: no-repeat; | |||
| padding-left: 1.67em; | |||
| content: ' '; | |||
| color: black; | |||
| font-size: 0.85em; | |||
| opacity: 0.67; | |||
| background-size: contain; | |||
| } | |||
| header { | |||
| padding: 0.2em 1em; | |||
| background-color: #eaecf0; | |||
| } | |||
| header > * { | |||
| vertical-align: middle; | |||
| display: inline-block; | |||
| } | |||
| #a-language:before { | |||
| content: ''; | |||
| background-image: url(https://commons.wikimedia.org/w/extensions/UniversalLanguageSelector/lib/jquery.uls/images/language.svg?80b0b); | |||
| background-repeat: no-repeat; | |||
| padding-left: 1.5em; | |||
| opacity: 0.67; | |||
| background-size: contain; | |||
| } | |||
| #a-language { | |||
| /* color: #54595d; */ | |||
| margin-right: 1em; | |||
| } | |||
| #cv-tmp1 > * { | |||
| vertical-align: middle; | |||
| } | |||
| .cv-tmp2 { | |||
| display: inline-block; | |||
| } | |||
| .cv-tmp2 > * { | |||
| vertical-align: middle; | |||
| } | |||
| .cv-tmp2:not(:last-child) { | |||
| margin-right: 1em; | |||
| } | |||
| ol, ul { | |||
| line-height:1.5; | |||
| } | |||
| h2{ | |||
| margin-bottom:.2em; | |||
| } | |||
| #cv-page-title { | |||
| max-width: 40em; | |||
| } | |||
| /* #header { | |||
| font-size:2.5em; | |||
| } */ | |||
| /* #container {} */ | |||
| #container { | |||
| min-height: 40em; | |||
| margin: auto; | |||
| } | |||
| footer{ | |||
| width: 100%; | |||
| font-size: .90em; | |||
| text-align: center; | |||
| line-height: 1.5; | |||
| border-top: 1px solid #c8ccd1; | |||
| background: #fff; | |||
| } | |||
| footer li { | |||
| display:inline; | |||
| } | |||
| footer li:not(:last-child):after{ | |||
| content:" \00b7"; | |||
| } | |||
| #notice-box{ | |||
| padding:10px; | |||
| margin:10px 5px; | |||
| } | |||
| #notice-box p{ | |||
| margin:.25em 0; | |||
| } | |||
| #notice-box ul{padding-left:1.5em;margin:0} | |||
| #info-box{padding:0 10px;margin:10px 5px} | |||
| #cv-result{padding:5px;margin:10px 5px} | |||
| #attribution-warning{padding:1em;margin:15px 5px 10px} | |||
| #turnitin-container{padding:5px;margin:15px 5px 10px} | |||
| #sources-container{padding:5px 10px;margin:15px 5px 10px;background-color:#eee;border:1px solid #bbb} | |||
| #sources-title,#turnitin-title{margin-bottom:-5px;text-align:center;font-weight:700} | |||
| #turnitin-summary{padding-bottom:5px;font-style:italic} | |||
| #cv-additional{display:none} | |||
| #generation-time{margin-right:5px;text-align:right;font-style:italic} | |||
| #cv-chain-container{margin:0 4px} | |||
| #heading{width:100%} | |||
| #cv-form-outer{table-layout: fixed;width: 100%;max-width: 60em;} | |||
| .cv-form-inner{table-layout: fixed;width:100%;border-spacing:0;} | |||
| #cv-result-sources{width:100%;border-spacing:0 4px;table-layout:fixed} | |||
| #cv-result-sources col:first-child{width:80%} | |||
| #cv-result-sources col:nth-child(2),#cv-result-sources col:nth-child(3){width:10%} | |||
| #cv-result-sources th{text-align:left} | |||
| #cv-result-sources tr:nth-child(2n){background-color:#e0e0e0} | |||
| #cv-result-sources td:first-child{overflow:hidden;word-wrap:break-word} | |||
| #cv-result-head-table{width:100%;text-align:center;table-layout:fixed;border-spacing:0} | |||
| #cv-result-head-table col:nth-child(odd){width:42.5%} | |||
| #cv-result-head-table col:nth-child(2){width:15%} | |||
| #cv-result-head-table td:nth-child(odd){font-size:1.25em;font-weight:700;overflow:hidden;word-wrap:break-word} | |||
| #cv-result-head-table td:nth-child(2) div:first-child{font-weight:700} | |||
| #cv-result-head-table td:nth-child(2) div:nth-child(2){font-size:2.5em;font-weight:700;line-height:1} | |||
| #cv-result-head-table td:nth-child(2) div:nth-child(3){font-size:.8em} | |||
| #cv-chain-table{margin-bottom:10px} | |||
| #cv-chain-table,#turnitin-table{width:100%;border-spacing:0;table-layout:fixed} | |||
| #turnitin-table{word-wrap:break-word} | |||
| #source-row-selected{background-color:#cfcfcf!important} | |||
| #head-settings{text-align:right} | |||
| #cv-col1{width:15%} | |||
| #cv-col2{width:55%} | |||
| #cv-col3{text-align:center} | |||
| #cv-col3,#cv-col4{width:15%} | |||
| #cv-inner-col1{width:4%} | |||
| #cv-inner-col2{width:22%} | |||
| #cv-inner-col3{width:76%} | |||
| #cv-result-header{margin:0} | |||
| #redirected-from{font-size:.75em;font-weight:400} | |||
| #redirected-from,#result-head-no-sources{font-style:italic} | |||
| #source-selected{font-weight:700} | |||
| #cv-cached{position:relative} | |||
| #cv-cached span{display:none;position:absolute;top:20px;left:-50px;width:500px;padding:5px;z-index:1;background:#f3f3f3;border:1px solid #aaa;color:#000;font-style:normal;text-align:left} | |||
| .green-box{background-color:#efe;border:1px solid #7f7} | |||
| .yellow-box{background-color:#ffd;border:1px solid #ee5} | |||
| .red-box{background-color:#fee;border:1px solid #f77} | |||
| .gray-box{background-color:#eee;border:1px solid #aaa} | |||
| .indentable{white-space:pre-wrap} | |||
| .cv-source-footer{padding-bottom:5px;font-style:italic} | |||
| .cv-source-footer a{font-style:normal} | |||
| .cv-chain-detail{padding:0 10px;background-color:#fff;border:1px solid #bbb} | |||
| .cv-chain-cell{vertical-align:top;word-wrap:break-word} | |||
| .cv-chain-cell:first-child{padding-right:5px} | |||
| .cv-chain-cell:last-child{padding-left:5px} | |||
| .turnitin-table-cell{padding:.5em 0 .3em} | |||
| .turnitin-table-cell ul{margin:.2em 0 0;line-height:1.4} | |||
| .cv-text{width:100%;} | |||
| .cv-search{margin-left:0;margin-right:5px} | |||
| .cv-search~.cv-search{margin-left:20px} | |||
| .cv-hl{background:#faa} | |||
| .cv-hl-in{background:#fcc;background:linear-gradient(270deg,#faa,#fff)} | |||
| .cv-hl-out{background:#fcc;background:linear-gradient(90deg,#faa,#fff)} | |||
| .mono{font-family:monospace} | |||
| .light{color:#ccc} | |||
| .medium{color:#aaa} | |||
| .source-similarity{font-weight:700} | |||
| .source-suspect{color:#900} | |||
| .source-possible{color:#990} | |||
| .source-novio{color:#090} | |||
| .source-excluded,.source-skipped{font-style:italic} | |||
| a:link,a:visited{color: #002bb8;text-decoration:none;} | |||
| /* a:hover{ color:#040 } */ | |||
| a:active,a:hover{text-decoration:underline} | |||
| a:active{color:#404} | |||
| #a-home:link,#a-home:visited{margin-right: 1em;font-size: 2em;/* font-weight: bold; */color: #54595d;} | |||
| #a-home:hover{color:#555} | |||
| #a-home:active{color:#333} | |||
| /* #a-settings:link,#a-settings:visited{color: #54595d;} */ | |||
| /* #a-settings:hover{} */ | |||
| #a-settings:active{color:#666} | |||
| #cv-cached:active{color:#040} | |||
| #cv-cached:active,#cv-cached:hover{text-decoration:none} | |||
| #cv-cached:hover span{display:block} | |||
| .source-url:link,.source-url:visited{color:#357} | |||
| .source-url:hover{color:#035} | |||
| .source-url:active{color:#404} | |||
| @@ -0,0 +1,37 @@ | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="utf-8"> | |||
| <title> | |||
| {% if title %} | |||
| {{ title }} - | |||
| {% endif %} | |||
| Earwig's Copyvio Detector | |||
| </title> | |||
| <link rel="stylesheet" href="/static/style.css"><!-- TODO: add hash --> | |||
| <style> | |||
| /* TODO: add dynamic background style */ | |||
| </style> | |||
| </head> | |||
| <body style="background-image: url('{{ background.image_url }}');"> | |||
| <div id="container"> | |||
| <div id="container-inner"> | |||
| <header> | |||
| <a id="a-home" href="/"> | |||
| Earwig's <strong>Copyvio Detector</strong></a> | |||
| <a id="a-language" href="/language">Language</a> | |||
| <a id="a-settings" href="/settings">Settings</a> | |||
| </header> | |||
| {% block content %} | |||
| {% endblock %} | |||
| </div> | |||
| </div> | |||
| <footer> | |||
| <ul> | |||
| <li>Maintained by <a href="https://en.wikipedia.org/wiki/User:The_Earwig">Ben Kurtovic</a></li> | |||
| <li><a href="/api">API</a></li> | |||
| <li><a href="https://github.com/earwig/copyvios">Source code</a></li> | |||
| <li><a href="{{ background.source_url }}">Background image</a></li> | |||
| </ul> | |||
| </footer> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,114 @@ | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="utf-8"> | |||
| <title>Earwig's Copyvio Detector</title> | |||
| <link rel="stylesheet" href="/static/style.css"><!-- TODO: add hash --> | |||
| <style> | |||
| /* TODO: add dynamic background style */ | |||
| </style> | |||
| </head> | |||
| <body style="background-image: url('{{ background.image_url }}');"> | |||
| <div id="container"> | |||
| <div id="container-inner"> | |||
| <header> | |||
| <a id="a-home" href="/"> | |||
| Earwig's <strong>Copyvio Detector</strong></a> | |||
| <a id="a-language" href="/language">Language</a> | |||
| <a id="a-settings" href="/settings">Settings</a> | |||
| </header> | |||
| <p>This tool attempts to detect <a href="https://en.wikipedia.org/wiki/WP:COPYVIO">copyright violations</a> in Wikipedia articles. In <i>search mode</i>, it will check for similar content elsewhere on the web using <a href="https://developers.google.com/custom-search/">Google</a>, external links present in the page, or <a href="https://en.wikipedia.org/wiki/Wikipedia:Turnitin">Turnitin</a> (via <a href="https://en.wikipedia.org/wiki/User:EranBot">EranBot</a>), depending on the selected options. In <i>comparison mode</i>, the tool will compare the article to a specific webpage without making any searches, like the <a href="https://dupdet.toolforge.org/">Duplication Detector</a>.</p> | |||
| <p>Be aware that other websites can copy from Wikipedia, so check the results carefully, especially for older or well-developed articles. Specific websites can be skipped by being added to the <a href="https://en.wikipedia.org/wiki/User:EarwigBot/Copyvios/Exclusions">excluded URL list</a>.</p> | |||
| <form id="cv-form" action="/" method="get"> | |||
| <table id="cv-form-outer"> | |||
| <tr> | |||
| <td style="width: 4em;">Site:</td> | |||
| <td colspan="3"> | |||
| <select name="lang" style="width: 10em;"> | |||
| <option value="en" selected="selected">en (English)</option> | |||
| </select> | |||
| <select name="project" style="margin-left: 1em;"> | |||
| <option value="wikipedia" selected="selected">Wikipedia</option> | |||
| </select> | |||
| </td> | |||
| </tr> | |||
| <tr> | |||
| <td id="cv-col1">Page:</td> | |||
| <td id="cv-col2" colspan="3"> | |||
| <table class="cv-form-inner"> | |||
| <tr> | |||
| <td style="width: 8em;"> | |||
| <label for="cv-page-title">Title:</label> | |||
| </td> | |||
| <td> | |||
| <input class="cv-text" type="text" name="title" id="cv-page-title"> | |||
| </td> | |||
| </tr> | |||
| <tr> | |||
| <td> | |||
| <label for="cv-rev-id">or revision ID:</label> | |||
| </td> | |||
| <td> | |||
| <input class="cv-text" type="text" name="oldid" id="cv-rev-id" style="width: 10em;"> | |||
| </td> | |||
| </tr> | |||
| </table> | |||
| </td> | |||
| </tr> | |||
| <tr> | |||
| <td>Action:</td> | |||
| <td colspan="3"> | |||
| <table class="cv-form-inner"> | |||
| <tr> | |||
| <td style="width: 12em;"> | |||
| <input id="action-search" type="radio" name="action" value="search" checked="checked" style="vertical-align: middle;"> | |||
| <label for="action-search" style="vertical-align: middle;">Copyvio search:</label> | |||
| </td> | |||
| <td id="cv-tmp1"> | |||
| <div class="cv-tmp2"> | |||
| <input type="hidden" name="use_engine" value="0"> | |||
| <input id="cv-cb-engine" class="cv-search" type="checkbox" name="use_engine" value="1" checked="checked"> | |||
| <label for="cv-cb-engine">Use search engine</label> | |||
| </div> | |||
| <div class="cv-tmp2"> | |||
| <input type="hidden" name="use_links" value="0"> | |||
| <input id="cv-cb-links" class="cv-search" type="checkbox" name="use_links" value="1" checked="checked"> | |||
| <label for="cv-cb-links">Use links in page</label> | |||
| </div> | |||
| <div class="cv-tmp2"> | |||
| <input type="hidden" name="turnitin" value="0"> | |||
| <input id="cv-cb-turnitin" class="cv-search" type="checkbox" name="turnitin" value="1"> | |||
| <label for="cv-cb-turnitin">Use Turnitin</label> | |||
| </div> | |||
| </td> | |||
| </tr> | |||
| <tr> | |||
| <td> | |||
| <input id="action-compare" type="radio" name="action" value="compare" style="vertical-align: middle;"> | |||
| <label for="action-compare" style="vertical-align: middle;">URL comparison:</label> | |||
| </td> | |||
| <td> | |||
| <input class="cv-compare cv-text" type="text" name="url" disabled=""> | |||
| </td> | |||
| </tr> | |||
| </table> | |||
| </td> | |||
| </tr> | |||
| <tr> | |||
| <td colspan="4"> | |||
| <input type="submit"> | |||
| </td> | |||
| </tr> | |||
| </table> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| <footer> | |||
| <ul> | |||
| <li>Maintained by <a href="https://en.wikipedia.org/wiki/User:The_Earwig">Ben Kurtovic</a></li> | |||
| <li><a href="/api">API</a></li> | |||
| <li><a href="https://github.com/earwig/copyvios">Source code</a></li> | |||
| <li><a href="{{ background.source_url }}">Background image</a></li> | |||
| </ul> | |||
| </footer> | |||
| </body> | |||
| </html> | |||