Add redirect support, live candidate list, per-article save count, and error pages

- Detect redirect targets (e.g. "handling stolen goods" → "Possession of
  stolen goods") and use piped links [[target|title]] in edits; exclude
  articles already linking to the redirect target from candidates
- Remove candidates from the list in real time as they are checked and
  found invalid, with live count update in the summary
- Track and display per-article save count in the stats line
- Rename "Find Link" to "Missing Link" throughout
- Show redirect target in the article heading
- Report save errors to the user via error page instead of crashing
- Filter self-links using case-insensitive first-letter comparison

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-11 13:53:35 +01:00
parent 0239b83555
commit c9b4e2face
3 changed files with 64 additions and 17 deletions

View file

@ -18,11 +18,17 @@
<div class="d-flex flex-wrap align-items-baseline gap-3 mb-1"> <div class="d-flex flex-wrap align-items-baseline gap-3 mb-1">
<h1 class="h4 mb-0">Find links to "{{ title }}"</h1> <h1 class="h4 mb-0">Find links to "{{ title }}"</h1>
<a href="https://en.wikipedia.org/wiki/{{ title }}" target="_blank" class="text-muted small">{{ title }} ↗</a> <a href="https://en.wikipedia.org/wiki/{{ title }}" target="_blank" class="text-muted small">{{ title }} ↗</a>
{% if redirect_to %}
<span class="text-muted small">→ redirects to <a href="https://en.wikipedia.org/wiki/{{ redirect_to }}" target="_blank">{{ redirect_to }} ↗</a></span>
{% endif %}
</div> </div>
<div class="d-flex gap-3 mb-3 text-muted small"> <div class="d-flex gap-3 mb-3 text-muted small">
<span>{{ total }} mentions total</span> <span>{{ total }} mentions total</span>
<span>{{ with_link }} already linked ({{ "{:.0%}".format(with_link / total) }})</span> <span>{{ with_link }} already linked ({{ "{:.0%}".format(with_link / total) }})</span>
{% if saves_this_session %}
<span class="text-success">{{ saves_this_session }} added this session</span>
{% endif %}
</div> </div>
<div id="search-progress" class="my-4"> <div id="search-progress" class="my-4">
@ -57,11 +63,11 @@
</div> </div>
{% if hits %} {% if hits %}
<details class="border rounded p-3 mt-2"> <details id="candidates-section" class="border rounded p-3 mt-2">
<summary class="text-muted small" style="cursor:pointer">{{ hits | length }} candidates</summary> <summary class="text-muted small" style="cursor:pointer"><span id="candidates-count">{{ hits | length }}</span> candidates</summary>
<ol class="mt-3 mb-0 small"> <ol class="mt-3 mb-0 small" id="candidates-list">
{% for hit in hits %} {% for hit in hits %}
<li class="mb-1"> <li class="mb-1" data-title="{{ hit.title }}">
<a href="{{ url_for("article_page", url_title=url_title, title=hit.title) }}">{{ hit.title }}</a> <a href="{{ url_for("article_page", url_title=url_title, title=hit.title) }}">{{ hit.title }}</a>
</li> </li>
{% endfor %} {% endfor %}
@ -76,6 +82,7 @@
(function () { (function () {
const hits = {{ hits | map(attribute='title') | list | tojson }}; const hits = {{ hits | map(attribute='title') | list | tojson }};
const linkTo = {{ title | tojson }}; const linkTo = {{ title | tojson }};
const redirectTo = {{ redirect_to | tojson }};
const apiUrl = {{ url_for('api_valid_hit') | tojson }}; const apiUrl = {{ url_for('api_valid_hit') | tojson }};
const pageUrl = new URL(window.location.href); const pageUrl = new URL(window.location.href);
@ -83,6 +90,16 @@
const elStatus = document.getElementById('search-status'); const elStatus = document.getElementById('search-status');
const elResult = document.getElementById('result'); const elResult = document.getElementById('result');
const elAllDone = document.getElementById('all-done'); const elAllDone = document.getElementById('all-done');
const elList = document.getElementById('candidates-list');
function removeCandidate(title) {
if (!elList) return;
const li = elList.querySelector(`li[data-title="${CSS.escape(title)}"]`);
if (!li) return;
li.remove();
const elCount = document.getElementById('candidates-count');
if (elCount) elCount.textContent = elList.children.length;
}
async function search() { async function search() {
for (const hitTitle of hits) { for (const hitTitle of hits) {
@ -91,6 +108,7 @@
let data; let data;
try { try {
const params = new URLSearchParams({ link_to: linkTo, link_from: hitTitle }); const params = new URLSearchParams({ link_to: linkTo, link_from: hitTitle });
if (redirectTo) params.append('redirect_to', redirectTo);
const resp = await fetch(apiUrl + '?' + params); const resp = await fetch(apiUrl + '?' + params);
if (!resp.ok) continue; if (!resp.ok) continue;
data = await resp.json(); data = await resp.json();
@ -98,7 +116,7 @@
continue; continue;
} }
if (!data.valid) continue; if (!data.valid) { removeCandidate(hitTitle); continue; }
elProgress.hidden = true; elProgress.hidden = true;
@ -123,6 +141,8 @@
} }
elProgress.hidden = true; elProgress.hidden = true;
const elCandidates = document.getElementById('candidates-section');
if (elCandidates) elCandidates.hidden = true;
elAllDone.hidden = false; elAllDone.hidden = false;
} }

View file

@ -5,14 +5,14 @@
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet"> <link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<title>{% block title %}{% endblock %} Find Link</title> <title>{% block title %}{% endblock %} Missing Link</title>
{% block style %}{% endblock %} {% block style %}{% endblock %}
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4"> <nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
<div class="container"> <div class="container">
<a class="navbar-brand fw-semibold" href="{{ url_for('index') }}">Find Link</a> <a class="navbar-brand fw-semibold" href="{{ url_for('index') }}">Missing Link</a>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<form class="d-flex" action="{{ url_for('index') }}"> <form class="d-flex" action="{{ url_for('index') }}">
<input class="form-control form-control-sm me-2" name="q" placeholder="Article title…" style="width:240px"> <input class="form-control form-control-sm me-2" name="q" placeholder="Article title…" style="width:240px">

View file

@ -96,14 +96,20 @@ def search_count(q: str) -> int:
return get_hit_count(article_title_to_search_query(q)) - 1 return get_hit_count(article_title_to_search_query(q)) - 1
def search_count_with_link(q: str) -> int: def search_count_with_link(q: str, redirect_to: str | None = None) -> int:
"""Articles in Wikipedia that include this search term and a link.""" """Articles in Wikipedia that include this search term and a link."""
return get_hit_count(article_title_to_search_query(q) + f' linksto:"{q}"') count = get_hit_count(article_title_to_search_query(q) + f' linksto:"{q}"')
if redirect_to:
count += get_hit_count(article_title_to_search_query(q) + f' linksto:"{redirect_to}"')
return count
def search_no_link(q: str) -> tuple[int, list[Hit]]: def search_no_link(q: str, redirect_to: str | None = None) -> tuple[int, list[Hit]]:
"""Search for mentions of article title with no link included.""" """Search for mentions of article title with no link included."""
query = run_search(article_title_to_search_query(q) + f' -linksto:"{q}"', "max") exclude = f' -linksto:"{q}"'
if redirect_to:
exclude += f' -linksto:"{redirect_to}"'
query = run_search(article_title_to_search_query(q) + exclude, "max")
return (query["searchinfo"]["totalhits"], query["search"]) return (query["searchinfo"]["totalhits"], query["search"])
@ -313,9 +319,15 @@ def handle_post(url_title: str) -> Response:
do_save(from_title, hit_title) do_save(from_title, hit_title)
except mediawiki_oauth.LoginNeeded: except mediawiki_oauth.LoginNeeded:
return flask.redirect(flask.url_for("start_oauth")) return flask.redirect(flask.url_for("start_oauth"))
except mediawiki_api.APIError as e: except (mediawiki_api.APIError, api.MediawikiError) as e:
return flask.make_response(f"Save failed: {e}", 502) return flask.make_response(
flask.render_template("error.html", message=f"Save failed: {e}"), 502
)
flask.session["saves"] = flask.session.get("saves", 0) + 1 flask.session["saves"] = flask.session.get("saves", 0) + 1
saves_by_title: dict[str, int] = flask.session.get("saves_by_title", {})
saves_by_title[from_title] = saves_by_title.get(from_title, 0) + 1
flask.session["saves_by_title"] = saves_by_title
flask.session.modified = True
_record_skip(from_title, hit_title) _record_skip(from_title, hit_title)
return flask.redirect( return flask.redirect(
flask.url_for("article_page", url_title=url_title, after=hit_title) flask.url_for("article_page", url_title=url_title, after=hit_title)
@ -330,10 +342,15 @@ def article_page(url_title: str) -> str | Response:
from_title = url_title.replace("_", " ").strip() from_title = url_title.replace("_", " ").strip()
try:
redirect_to = api.get_wiki_info(from_title)
except (api.MissingPage, api.MultipleRedirects, api.MediawikiError):
redirect_to = None
try: try:
total = search_count(from_title) total = search_count(from_title)
with_link = search_count_with_link(from_title) with_link = search_count_with_link(from_title, redirect_to)
_no_link_count, hits = search_no_link(from_title) _no_link_count, hits = search_no_link(from_title, redirect_to)
except api.MediawikiError as e: except api.MediawikiError as e:
return flask.make_response( return flask.make_response(
flask.render_template("error.html", message=str(e)), 502 flask.render_template("error.html", message=str(e)), 502
@ -362,13 +379,17 @@ def article_page(url_title: str) -> str | Response:
if not hits: if not hits:
return flask.render_template("all_done.html") return flask.render_template("all_done.html")
saves_this_session = flask.session.get("saves_by_title", {}).get(from_title, 0)
return flask.render_template( return flask.render_template(
"article.html", "article.html",
title=from_title, title=from_title,
redirect_to=redirect_to,
total=total, total=total,
with_link=with_link, with_link=with_link,
hits=hits, hits=hits,
url_title=url_title, url_title=url_title,
saves_this_session=saves_this_session,
) )
@ -376,7 +397,12 @@ def do_save(title: str, hit_title: str) -> str:
"""Update page on Wikipedia.""" """Update page on Wikipedia."""
token = mediawiki_oauth.get_token() token = mediawiki_oauth.get_token()
found = get_match(title, hit_title, None) try:
redirect_to = api.get_wiki_info(title)
except (api.MissingPage, api.MultipleRedirects, api.MediawikiError):
redirect_to = None
found = get_match(title, hit_title, redirect_to)
summary = ( summary = (
f"link [[{found['replacement']}]] using [[:en:User:Edward/Find link|Find link]]" f"link [[{found['replacement']}]] using [[:en:User:Edward/Find link|Find link]]"
@ -417,9 +443,10 @@ def api_valid_hit() -> werkzeug.wrappers.response.Response:
"""Check if a candidate article has a valid unlinked mention.""" """Check if a candidate article has a valid unlinked mention."""
link_to = flask.request.args["link_to"] link_to = flask.request.args["link_to"]
link_from = flask.request.args["link_from"] link_from = flask.request.args["link_from"]
redirect_to = flask.request.args.get("redirect_to") or None
try: try:
found = get_diff(link_to, link_from, None) found = get_diff(link_to, link_from, redirect_to)
except NoMatch: except NoMatch:
_record_skip(link_to, link_from) _record_skip(link_to, link_from)
return flask.jsonify(valid=False) return flask.jsonify(valid=False)