feat: fetch full articles from the Portuguese Minecraft Wiki

This commit is contained in:
marcos
2026-08-05 15:17:01 +00:00
parent 9ffcf816ea
commit 13fb87fb8b
2 changed files with 231 additions and 0 deletions
@@ -0,0 +1,131 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/**
* Reads articles from the Portuguese Minecraft Wiki.
*
* <p>Full article text, not {@code exintro}: grounded on lead paragraphs alone
* the model answered "não tenho certeza" to questions it could otherwise get
* right, because the specifics live further down the page.
*
* <p>Note that {@code explaintext} drops tables, so crafting and brewing
* recipes never appear here. Those come from the running server's recipe
* registry instead.
*/
final class Wiki {
private static final String API = "https://pt.minecraft.wiki/api.php";
record Article(String title, String text) {
}
private final Fetcher fetcher;
private final int maxChars;
/**
* Keyed by the normalised <em>search term</em>, not by article title: two
* terms that resolve to the same article get their own entry, which costs a
* duplicate copy of the text and saves a round trip on each. Newest-last,
* evicted at 200 entries. Lost on restart, which is fine.
*
* <p>Guarded by its own monitor. {@link Ai} calls {@link #lookup} from
* {@code runTaskAsynchronously}, so several players asking at once means
* several threads in here at the same time, and an unsynchronised
* {@code LinkedHashMap} can corrupt its own links under a concurrent write.
* The lock is never held across a network call.
*/
private final Map<String, Article> cache = new LinkedHashMap<>();
Wiki(Fetcher fetcher, int maxChars) {
this.fetcher = fetcher;
this.maxChars = maxChars;
}
/** The best article for a search term, or null if there is none. */
Article lookup(String term) {
String key = key(term);
synchronized (cache) {
Article cached = cache.get(key);
if (cached != null) {
return cached;
}
}
try {
String title = search(term);
if (title == null) {
return null;
}
String text = extract(title);
if (text == null || text.isBlank()) {
return null;
}
Article article = new Article(title, trim(text));
remember(key, article);
return article;
} catch (Exception e) {
// A wiki outage must not break the answer; the caller falls back
// to answering without a source and says so.
return null;
}
}
private String search(String term) throws Exception {
String url = API + "?action=query&list=search&format=json&srlimit=1&srsearch="
+ URLEncoder.encode(term, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
if (!root.has("query")) {
return null;
}
JsonArray hits = root.getAsJsonObject("query").getAsJsonArray("search");
return hits.isEmpty() ? null : hits.get(0).getAsJsonObject().get("title").getAsString();
}
private String extract(String title) throws Exception {
String url = API + "?action=query&prop=extracts&explaintext=1&format=json&titles="
+ URLEncoder.encode(title, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
if (!root.has("query")) {
return null;
}
JsonObject pages = root.getAsJsonObject("query").getAsJsonObject("pages");
for (String key : pages.keySet()) {
JsonObject page = pages.getAsJsonObject(key);
if (page.has("extract")) {
return page.get("extract").getAsString();
}
}
return null;
}
private String trim(String text) {
String collapsed = text.replaceAll("\n{2,}", "\n").trim();
return collapsed.length() > maxChars ? collapsed.substring(0, maxChars) : collapsed;
}
/**
* {@code Locale.ROOT} rather than the default locale: a server started
* under a Turkish locale lowercases "I" to a dotless "ı", and any code that
* later calls {@code Locale.setDefault} would leave earlier entries keyed
* under rules no lookup uses again.
*/
private static String key(String term) {
return term.toLowerCase(Locale.ROOT);
}
private void remember(String key, Article article) {
synchronized (cache) {
cache.put(key, article);
while (cache.size() > 200) {
cache.remove(cache.keySet().iterator().next());
}
}
}
}