From e022872767f088acc944a429d5d86b028d79c69b Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 22 Jul 2026 10:57:28 +0100 Subject: [PATCH] Avoid self-link suggestions for redirect targets --- test_web_view.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ web_view.py | 43 +++++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/test_web_view.py b/test_web_view.py index af1e203..ba2392b 100644 --- a/test_web_view.py +++ b/test_web_view.py @@ -55,6 +55,61 @@ class ArticlePageSkipTests(unittest.TestCase): ) self.assertIn("Useful candidate", body) + def test_redirect_target_is_left_out_of_candidates(self) -> None: + hits = [ + hit("Documentary film"), + hit("Useful candidate"), + ] + + with ( + patch("web_view.api.get_wiki_info", return_value="Documentary film"), + patch("web_view.search_count", return_value=2), + patch("web_view.search_count_with_link", return_value=0), + patch("web_view.search_no_link", return_value=(2, hits)), + ): + response = self.client.get("/link/DVD_documentary") + + body = response.get_data(as_text=True) + self.assertEqual(response.status_code, 200) + self.assertNotIn('data-title="Documentary film"', body) + self.assertIn("Useful candidate", body) + + def test_redirect_target_case_variant_is_left_out_of_candidates(self) -> None: + hits = [hit("documentary film")] + + with ( + patch("web_view.api.get_wiki_info", return_value="Documentary film"), + patch("web_view.search_count", return_value=1), + patch("web_view.search_count_with_link", return_value=0), + patch("web_view.search_no_link", return_value=(1, hits)), + ): + response = self.client.get("/link/DVD_documentary") + + body = response.get_data(as_text=True) + self.assertEqual(response.status_code, 200) + self.assertIn("No more candidates found for this article.", body) + + +class ValidHitTests(unittest.TestCase): + def setUp(self) -> None: + web_view.app.config["TESTING"] = True + self.client = web_view.app.test_client() + + def test_redirect_target_is_not_valid_hit(self) -> None: + with patch("web_view.get_diff") as get_diff: + response = self.client.get( + "/api/1/valid_hit", + query_string={ + "link_to": "DVD documentary", + "link_from": "Documentary film", + "redirect_to": "Documentary film", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json(), {"valid": False}) + get_diff.assert_not_called() + class ProtectedCandidateTests(unittest.TestCase): def test_edit_protected_hits_are_filtered_out(self) -> None: diff --git a/web_view.py b/web_view.py index 748857d..85a9ce4 100755 --- a/web_view.py +++ b/web_view.py @@ -104,6 +104,28 @@ def article_url(title: str) -> str: return flask.url_for("article_page", url_title=title.replace(" ", "_")) +def canonical_title_key(title: str) -> str: + """Normalize a Wikipedia article title for same-page comparisons.""" + title = title.replace("_", " ").strip() + return title[:1].casefold() + title[1:] + + +def same_article_title(a: str | None, b: str | None) -> bool: + """Compare titles using Wikipedia's first-letter case-insensitive rule.""" + if not a or not b: + return False + return canonical_title_key(a) == canonical_title_key(b) + + +def is_self_link_candidate( + hit_title: str, from_title: str, redirect_to: str | None = None +) -> bool: + """Return true when a candidate is the source article or its redirect target.""" + return same_article_title(hit_title, from_title) or same_article_title( + hit_title, redirect_to + ) + + def get_hit_count(q: str) -> int: """Search Wikipedia and return hit count.""" return typing.cast(int, run_search(q, limit=0)["searchinfo"]["totalhits"]) @@ -391,6 +413,8 @@ def handle_post(url_title: str) -> Response: except mediawiki_oauth.LoginNeeded: next_url = flask.url_for("article_page", url_title=url_title, title=hit_title) return flask.redirect(flask.url_for("start_oauth", next=next_url)) + except NoMatch: + return flask.make_response(render_error("No valid unlinked mention found."), 400) except (mediawiki_api.APIError, api.MediawikiError) as e: return flask.make_response( render_mediawiki_error(e, prefix="Save failed"), 502 @@ -437,8 +461,12 @@ def article_page(url_title: str) -> str | Response: hits = [h for h in hits if h["title"] == title_param] + \ [h for h in hits if h["title"] != title_param] - hits = [h for h in hits if h["title"] not in session_skipped - and h["title"] != from_title and h["title"] != case_flip_first(from_title)] + hits = [ + h + for h in hits + if h["title"] not in session_skipped + and not is_self_link_candidate(h["title"], from_title, redirect_to) + ] if not hits: return flask.render_template("all_done.html") @@ -460,13 +488,16 @@ def article_page(url_title: str) -> str | Response: def do_save(title: str, hit_title: str) -> str: """Update page on Wikipedia.""" - token = mediawiki_oauth.get_token() - try: redirect_to = api.get_wiki_info(title) except (api.MissingPage, api.MultipleRedirects, api.MediawikiError): redirect_to = None + if is_self_link_candidate(hit_title, title, redirect_to): + raise NoMatch + + token = mediawiki_oauth.get_token() + found = get_match(title, hit_title, redirect_to) summary = ( @@ -510,6 +541,10 @@ def api_valid_hit() -> werkzeug.wrappers.response.Response: link_from = flask.request.args["link_from"] redirect_to = flask.request.args.get("redirect_to") or None + if is_self_link_candidate(link_from, link_to, redirect_to): + _record_skip(link_to, link_from) + return flask.jsonify(valid=False) + try: found = get_diff(link_to, link_from, redirect_to) except NoMatch: