Created
December 25, 2024 20:38
-
-
Save tvararu/d18b982a0bffc358dec302c4c5f4f445 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env elixir | |
Mix.install([ | |
{:plug_cowboy, "~> 2.5"}, | |
{:jason, "~> 1.4"}, | |
{:finch, "~> 0.16"} | |
]) | |
defmodule MetaculusQuestion do | |
@enforce_keys [:title, :id, :forecasters, :yes_prob, :no_prob, :distance_from_fifty, :resolve_time] | |
defstruct @enforce_keys | |
def from_map(data) do | |
title = Map.get(data, "title", "No title") | |
id = Map.get(data, "id", "N/A") | |
forecasters = Map.get(data, "nr_forecasters", "N/A") | |
aggs = get_in(data, ["question", "aggregations"]) || %{} | |
latest = get_in(aggs, ["recency_weighted", "latest"]) || %{} | |
vals = Map.get(latest, "forecast_values", [nil, nil]) | |
vals = | |
if is_list(vals) and length(vals) >= 2 do | |
vals | |
else | |
[nil, nil] | |
end | |
[yes_prob, no_prob] = vals | |
distance_from_fifty = | |
if yes_prob, do: abs(0.5 - yes_prob), else: :infinity | |
resolve_time = Map.get(data, "scheduled_resolve_time", "") | |
%__MODULE__{ | |
title: title, | |
id: id, | |
forecasters: forecasters, | |
yes_prob: yes_prob, | |
no_prob: no_prob, | |
distance_from_fifty: distance_from_fifty, | |
resolve_time: resolve_time | |
} | |
end | |
def to_row(%__MODULE__{} = q) do | |
yes_str = if q.yes_prob, do: :io_lib.format("~.1f%", [q.yes_prob * 100]) |> List.to_string(), else: "N/A" | |
no_str = if q.no_prob, do: :io_lib.format("~.1f%", [q.no_prob * 100]) |> List.to_string(), else: "N/A" | |
date_str = parse_date(q.resolve_time) | |
""" | |
<tr> | |
<td>#{q.title}</td> | |
<td class="yes-prob">#{yes_str}</td> | |
<td class="no-prob">#{no_str}</td> | |
<td>#{q.forecasters}</td> | |
<td>#{date_str}</td> | |
<td><a href="https://www.metaculus.com/questions/#{q.id}" target="_blank">View</a></td> | |
</tr> | |
""" | |
end | |
defp parse_date(time_str) do | |
# Attempt parsing with microseconds, then without, else "N/A" | |
case DateTime.from_iso8601(time_str) do | |
{:ok, dt, _offset} -> | |
dt | |
|> DateTime.to_date() | |
|> Date.to_string() | |
_ -> | |
"N/A" | |
end | |
end | |
end | |
defmodule MetaculusFetcher do | |
use GenServer | |
@initial_state %{results: []} | |
@fetch_interval_ms 300_000 # 5 minutes | |
def start_link(_args), do: GenServer.start_link(__MODULE__, @initial_state, name: __MODULE__) | |
def init(state) do | |
schedule_fetch() | |
{:ok, state} | |
end | |
def handle_info(:fetch_data, state) do | |
results = do_fetch() | |
schedule_fetch() | |
{:noreply, %{state | results: results}} | |
end | |
# Retrieve current data from this GenServer | |
def current_data, do: GenServer.call(__MODULE__, :get_data) | |
def handle_call(:get_data, _from, state), do: {:reply, state, state} | |
defp schedule_fetch, do: Process.send_after(self(), :fetch_data, @fetch_interval_ms) | |
defp do_fetch do | |
url = "https://www.metaculus.com/api/posts/?" <> | |
"statuses=open&with_cp=true&forecast_type=binary&order_by=scheduled_resolve_time&" <> | |
"scheduled_resolve_time__lt=#{URI.encode_www_form("2025-01-05T00:00:00Z")}" | |
# Replace with your token | |
headers = [ | |
{"Authorization", "THE_TOKEN"} | |
] | |
# Finch is the default HTTP client in modern Elixir | |
Finch.start_link(name: MyFinch) | |
all_results = fetch_paginated(url, headers, [], 1) | |
IO.puts("Data updated at #{Time.utc_now()}, total questions: #{length(all_results)}") | |
all_results | |
end | |
# Recursively fetch up to 9 pages | |
defp fetch_paginated(nil, _headers, acc, _page_count), do: acc | |
defp fetch_paginated(_url, _headers, acc, page_count) when page_count > 9, do: acc | |
defp fetch_paginated(url, headers, acc, page_count) do | |
uri = URI.parse(url) | |
request = Finch.build(:get, uri, headers) | |
case Finch.request(request, MyFinch) do | |
{:ok, %{status: 200, body: body}} -> | |
data = Jason.decode!(body) | |
results = data["results"] || [] | |
IO.puts("Fetched page #{page_count}, got #{length(results)} questions") | |
fetch_paginated( | |
data["next"], | |
headers, | |
acc ++ results, | |
page_count + 1 | |
) | |
{:ok, resp} -> | |
IO.puts("Request failed with HTTP status #{resp.status}") | |
acc | |
{:error, reason} -> | |
IO.puts("Error: #{inspect(reason)}") | |
acc | |
end | |
end | |
end | |
defmodule MetaculusPlug do | |
import Plug.Conn | |
def init(opts), do: opts | |
def call(conn, _opts) do | |
if conn.request_path == "/" do | |
data = MetaculusFetcher.current_data() | |
body = build_html(data.results) | |
send_resp(conn, 200, body) | |
else | |
send_resp(conn, 404, "Not Found") | |
end | |
end | |
defp build_html(results) do | |
questions = | |
results | |
|> Enum.map(&MetaculusQuestion.from_map/1) | |
|> Enum.sort_by(& &1.distance_from_fifty) | |
rows = Enum.map_join(questions, &MetaculusQuestion.to_row/1) | |
~s""" | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Metaculus Questions - Closest to 50/50</title> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
margin: 20px; | |
} | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
margin-top: 20px; | |
} | |
th, td { | |
border: 1px solid #ddd; | |
padding: 8px; | |
text-align: left; | |
} | |
th { | |
background-color: #f4f4f4; | |
} | |
.yes-prob { | |
color: green; | |
} | |
.no-prob { | |
color: red; | |
} | |
.last-updated { | |
color: #666; | |
font-size: 0.9em; | |
margin-top: 10px; | |
} | |
tr:nth-child(even) { | |
background-color: #f9f9f9; | |
} | |
</style> | |
</head> | |
<body> | |
<h1>Metaculus Questions - Resolving Before Jan 5 2025 (#{length(questions)} questions)</h1> | |
<p>Sorted by closest to 50% probability</p> | |
<table> | |
<thead> | |
<tr> | |
<th>Question</th> | |
<th>Yes</th> | |
<th>No</th> | |
<th>Forecasters</th> | |
<th>Resolves</th> | |
<th>Link</th> | |
</tr> | |
</thead> | |
<tbody> | |
#{rows} | |
</tbody> | |
</table> | |
<div class="last-updated"> | |
Last updated: #{NaiveDateTime.local_now() |> NaiveDateTime.truncate(:second)} | |
</div> | |
<script> | |
// Reload page every 5 minutes | |
setTimeout(() => { window.location.reload(); }, 5 * 60 * 1000); | |
</script> | |
</body> | |
</html> | |
""" | |
end | |
end | |
defmodule MetaculusServer do | |
def start(port \\ 8000) do | |
# Start background fetcher | |
{:ok, _pid} = MetaculusFetcher.start_link([]) | |
# Start Plug/Cowboy | |
Plug.Cowboy.http(MetaculusPlug, [], port: port) | |
IO.puts("Server running on port #{port}") | |
# Wait forever | |
:timer.sleep(:infinity) | |
end | |
end | |
# Run if we're invoked as a script | |
if __ENV__.file == __FILE__ do | |
MetaculusServer.start(8000) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment