Compare commits
7 commits
eecbd3cfb8
...
118ec9fc9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 118ec9fc9b | |||
| e022872767 | |||
| d1efae7745 | |||
| 165acb8e2e | |||
| 93f49c5554 | |||
| bd0c1a26c2 | |||
| 9486d9cb8a |
14 changed files with 823 additions and 50 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
|
|
@ -8,6 +10,7 @@ from simplejson.scanner import JSONDecodeError
|
||||||
|
|
||||||
from .language import get_current_language
|
from .language import get_current_language
|
||||||
from .util import is_disambig
|
from .util import is_disambig
|
||||||
|
from .wikimedia_api_logging import WikimediaApiLogConfig, logged_request
|
||||||
|
|
||||||
StrDict = dict[str, typing.Any]
|
StrDict = dict[str, typing.Any]
|
||||||
|
|
||||||
|
|
@ -15,6 +18,15 @@ ua = (
|
||||||
"find-link/2.2 "
|
"find-link/2.2 "
|
||||||
+ "(https://github.com/EdwardBetts/find_link; contact: edward@4angle.com)"
|
+ "(https://github.com/EdwardBetts/find_link; contact: edward@4angle.com)"
|
||||||
)
|
)
|
||||||
|
wikimedia_log_config = WikimediaApiLogConfig(
|
||||||
|
tool="missinglink",
|
||||||
|
log_path=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"MISSINGLINK_WIKIMEDIA_API_LOG", "/var/log/missinglink/wikimedia-api.jsonl"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
user_agent=ua,
|
||||||
|
)
|
||||||
re_disambig = re.compile(r"^(.*) \((.*)\)$")
|
re_disambig = re.compile(r"^(.*) \((.*)\)$")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,23 +85,40 @@ webpage_error = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_active_session() -> requests.sessions.Session:
|
def _get_request_oauth_session() -> requests.sessions.Session | None:
|
||||||
"""Return OAuth session if one is available in Flask context, else plain session."""
|
"""Return the logged-in user's OAuth session in a Flask request, if present."""
|
||||||
try:
|
try:
|
||||||
from flask import g
|
from flask import g, has_request_context
|
||||||
if hasattr(g, "oauth_session") and g.oauth_session is not None:
|
|
||||||
return g.oauth_session # type: ignore[return-value]
|
if not has_request_context():
|
||||||
|
return None
|
||||||
|
|
||||||
|
oauth_session = getattr(g, "oauth_session", None)
|
||||||
|
if oauth_session is not None:
|
||||||
|
return typing.cast(requests.sessions.Session, oauth_session)
|
||||||
|
|
||||||
|
from . import mediawiki_oauth
|
||||||
|
|
||||||
|
return typing.cast(
|
||||||
|
requests.sessions.Session | None, mediawiki_oauth.get_oauth_session()
|
||||||
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_session() -> tuple[requests.sessions.Session, str]:
|
||||||
|
"""Return active session and auth mode."""
|
||||||
|
if oauth_session := _get_request_oauth_session():
|
||||||
|
return oauth_session, "oauth"
|
||||||
|
|
||||||
print("WARNING: using unauthenticated session", file=sys.stderr)
|
print("WARNING: using unauthenticated session", file=sys.stderr)
|
||||||
return get_session()
|
return get_session(), "anon"
|
||||||
|
|
||||||
|
|
||||||
def api_get(params: StrDict) -> StrDict:
|
def api_get(params: StrDict) -> StrDict:
|
||||||
"""Make call to Wikipedia API."""
|
"""Make call to Wikipedia API."""
|
||||||
s = _get_active_session()
|
s, _auth = _get_active_session()
|
||||||
|
r = logged_request(s, wikimedia_log_config, "GET", get_query_url(), params=params)
|
||||||
r = s.get(get_query_url(), params=params)
|
|
||||||
try:
|
try:
|
||||||
ret: StrDict = r.json()
|
ret: StrDict = r.json()
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
|
|
@ -287,8 +316,8 @@ def call_get_diff(title: str, section_num: int, section_text: str) -> str:
|
||||||
"rvdifftotext": section_text.strip(),
|
"rvdifftotext": section_text.strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
s = _get_active_session()
|
s, _auth = _get_active_session()
|
||||||
r = s.post(get_query_url(), data=data)
|
r = logged_request(s, wikimedia_log_config, "POST", get_query_url(), data=data)
|
||||||
try:
|
try:
|
||||||
ret = r.json()
|
ret = r.json()
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,15 @@ def find_cite_template_spans(text: str) -> list[tuple[int, int]]:
|
||||||
def parse_cite(text: str) -> typing.Iterator[tuple[str, str]]:
|
def parse_cite(text: str) -> typing.Iterator[tuple[str, str]]:
|
||||||
"""Parse citations yielding (type, chunk) tuples, skipping ref tags, cite templates, and external links."""
|
"""Parse citations yielding (type, chunk) tuples, skipping ref tags, cite templates, and external links."""
|
||||||
regions = [(m.start(), m.end()) for m in re_cite.finditer(text)]
|
regions = [(m.start(), m.end()) for m in re_cite.finditer(text)]
|
||||||
|
link_spans = [(m.start(), m.end()) for m in re_link_in_text.finditer(text)]
|
||||||
regions.extend(find_cite_template_spans(text))
|
regions.extend(find_cite_template_spans(text))
|
||||||
regions.extend((m.start(), m.end()) for m in re_no_param_template.finditer(text))
|
regions.extend((m.start(), m.end()) for m in re_no_param_template.finditer(text))
|
||||||
regions.extend((m.start(), m.end()) for m in re_external_link.finditer(text))
|
regions.extend((m.start(), m.end()) for m in re_external_link.finditer(text))
|
||||||
regions.extend((m.start(), m.end()) for m in re_italic.finditer(text))
|
regions.extend(
|
||||||
|
(m.start(), m.end())
|
||||||
|
for m in re_italic.finditer(text)
|
||||||
|
if not any(start <= m.start() and m.end() <= end for start, end in link_spans)
|
||||||
|
)
|
||||||
regions.extend((m.start(), m.end()) for m in re_bullet_with_url.finditer(text))
|
regions.extend((m.start(), m.end()) for m in re_bullet_with_url.finditer(text))
|
||||||
regions.sort()
|
regions.sort()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Interface with the mediawiki API."""
|
"""Interface with the mediawiki API."""
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
from pprint import pprint
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -12,6 +11,17 @@ from . import mediawiki_oauth
|
||||||
class APIError(Exception):
|
class APIError(Exception):
|
||||||
"""Unexpected response from the MediaWiki API."""
|
"""Unexpected response from the MediaWiki API."""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_api_error(data: dict[str, Any]) -> str:
|
||||||
|
"""Format a MediaWiki API error response."""
|
||||||
|
error = data.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
message = error.get("info") or error.get("code")
|
||||||
|
if message:
|
||||||
|
return str(message)
|
||||||
|
return f"Unexpected MediaWiki API response: {data!r}"
|
||||||
|
|
||||||
|
|
||||||
wiki_hostname = "en.wikipedia.org"
|
wiki_hostname = "en.wikipedia.org"
|
||||||
wiki_api_php = f"https://{wiki_hostname}/w/api.php"
|
wiki_api_php = f"https://{wiki_hostname}/w/api.php"
|
||||||
user_agent = "add-links/0.1"
|
user_agent = "add-links/0.1"
|
||||||
|
|
@ -103,8 +113,5 @@ def edit_page(
|
||||||
}
|
}
|
||||||
ret = call(params, timeout=30)
|
ret = call(params, timeout=30)
|
||||||
if "edit" not in ret:
|
if "edit" not in ret:
|
||||||
print("params")
|
raise APIError(_format_api_error(ret))
|
||||||
pprint(params)
|
|
||||||
print()
|
|
||||||
pprint(ret)
|
|
||||||
return typing.cast(str, ret["edit"])
|
return typing.cast(str, ret["edit"])
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from .api import wikimedia_log_config
|
||||||
|
from .wikimedia_api_logging import logged_request
|
||||||
|
|
||||||
wiki_hostname = "en.wikipedia.org"
|
wiki_hostname = "en.wikipedia.org"
|
||||||
wiki_api_php = f"https://{wiki_hostname}/w/api.php"
|
wiki_api_php = f"https://{wiki_hostname}/w/api.php"
|
||||||
user_agent = "dab-mechanic/0.1"
|
user_agent = "dab-mechanic/0.1"
|
||||||
|
|
@ -27,8 +30,10 @@ def parse_page(enwiki: str) -> dict[str, Any]:
|
||||||
|
|
||||||
def get(params: dict[str, str | int]) -> dict[str, Any]:
|
def get(params: dict[str, str | int]) -> dict[str, Any]:
|
||||||
"""Make GET request to mediawiki API."""
|
"""Make GET request to mediawiki API."""
|
||||||
data: dict[str, Any] = requests.get(
|
session = requests.Session()
|
||||||
wiki_api_php, headers={"User-Agent": user_agent}, params=params
|
session.headers = {"User-Agent": user_agent}
|
||||||
|
data: dict[str, Any] = logged_request(
|
||||||
|
session, wikimedia_log_config, "GET", wiki_api_php, params=params
|
||||||
).json()
|
).json()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import requests
|
||||||
from flask import current_app, session
|
from flask import current_app, session
|
||||||
from requests_oauthlib import OAuth1Session
|
from requests_oauthlib import OAuth1Session
|
||||||
|
|
||||||
from .api import ua
|
from .api import ua, wikimedia_log_config
|
||||||
|
from .wikimedia_api_logging import logged_request
|
||||||
|
|
||||||
wiki_hostname = "en.wikipedia.org"
|
wiki_hostname = "en.wikipedia.org"
|
||||||
api_url = f"https://{wiki_hostname}/w/api.php"
|
api_url = f"https://{wiki_hostname}/w/api.php"
|
||||||
|
|
@ -44,7 +45,15 @@ def api_post_request(params: dict[str, str | int], timeout: int = 4) -> requests
|
||||||
)
|
)
|
||||||
oauth.headers.update({"User-Agent": ua})
|
oauth.headers.update({"User-Agent": ua})
|
||||||
proxies = get_edit_proxy()
|
proxies = get_edit_proxy()
|
||||||
return oauth.post(api_url, data=params, timeout=timeout, proxies=proxies)
|
return logged_request(
|
||||||
|
oauth,
|
||||||
|
wikimedia_log_config,
|
||||||
|
"POST",
|
||||||
|
api_url,
|
||||||
|
data=params,
|
||||||
|
timeout=timeout,
|
||||||
|
proxies=proxies,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def raw_request(params: typing.Mapping[str, str | int]) -> requests.Response:
|
def raw_request(params: typing.Mapping[str, str | int]) -> requests.Response:
|
||||||
|
|
@ -63,8 +72,13 @@ def raw_request(params: typing.Mapping[str, str | int]) -> requests.Response:
|
||||||
)
|
)
|
||||||
oauth.headers.update({"User-Agent": ua})
|
oauth.headers.update({"User-Agent": ua})
|
||||||
proxies = get_edit_proxy()
|
proxies = get_edit_proxy()
|
||||||
return oauth.get(
|
return logged_request(
|
||||||
api_url + "?" + urllib.parse.urlencode(params), timeout=4, proxies=proxies
|
oauth,
|
||||||
|
wikimedia_log_config,
|
||||||
|
"GET",
|
||||||
|
api_url + "?" + urllib.parse.urlencode(params),
|
||||||
|
timeout=4,
|
||||||
|
proxies=proxies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
222
add_links/wikimedia_api_logging.py
Normal file
222
add_links/wikimedia_api_logging.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""JSONL logging helpers for Wikimedia API request metrics."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WikimediaApiLogConfig:
|
||||||
|
"""Configuration for Wikimedia API request logging."""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
log_path: Path
|
||||||
|
user_agent: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WikimediaApiRequestMetric:
|
||||||
|
"""Details of one Wikimedia API request."""
|
||||||
|
|
||||||
|
tool: str
|
||||||
|
url: str
|
||||||
|
method: str
|
||||||
|
status_code: int | None
|
||||||
|
elapsed_ms: int
|
||||||
|
user_agent: str
|
||||||
|
request_params: Mapping[str, object] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_logger_cache: dict[Path, logging.Logger] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_wikimedia_api_logger(log_path: Path) -> logging.Logger:
|
||||||
|
"""Create a JSONL logger for Wikimedia API request metrics."""
|
||||||
|
if log_path in _logger_cache:
|
||||||
|
return _logger_cache[log_path]
|
||||||
|
|
||||||
|
logger_name = f"wikimedia_api_metrics.{log_path}"
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
try:
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler: logging.Handler = logging.FileHandler(log_path)
|
||||||
|
except OSError:
|
||||||
|
handler = logging.NullHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
_logger_cache[log_path] = logger
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_mediawiki_action(
|
||||||
|
url: str, request_params: Mapping[str, object] | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Extract the MediaWiki API action from params or a URL, if present."""
|
||||||
|
if request_params is not None:
|
||||||
|
action = request_params.get("action")
|
||||||
|
if isinstance(action, str):
|
||||||
|
return action
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
values = query.get("action")
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return values[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_flask_request_context() -> tuple[str | None, str | None]:
|
||||||
|
"""Return Flask endpoint and browser URL when running in a request."""
|
||||||
|
try:
|
||||||
|
from flask import has_request_context, request
|
||||||
|
|
||||||
|
if not has_request_context():
|
||||||
|
return None, None
|
||||||
|
return request.endpoint, request.url
|
||||||
|
except RuntimeError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_record(metric: WikimediaApiRequestMetric) -> dict[str, object]:
|
||||||
|
"""Build a JSON-serialisable log record for one API request."""
|
||||||
|
parsed = urlparse(metric.url)
|
||||||
|
flask_endpoint, flask_url = get_flask_request_context()
|
||||||
|
|
||||||
|
record: dict[str, object] = {
|
||||||
|
"ts": datetime.now(UTC).isoformat(),
|
||||||
|
"tool": metric.tool,
|
||||||
|
"host": socket.gethostname(),
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"method": metric.method,
|
||||||
|
"api_host": parsed.netloc,
|
||||||
|
"path": parsed.path,
|
||||||
|
"action": get_mediawiki_action(metric.url, metric.request_params),
|
||||||
|
"status_code": metric.status_code,
|
||||||
|
"elapsed_ms": metric.elapsed_ms,
|
||||||
|
"user_agent": metric.user_agent,
|
||||||
|
"flask_endpoint": flask_endpoint,
|
||||||
|
"flask_url": flask_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if metric.error is not None:
|
||||||
|
record["error"] = metric.error
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def log_wikimedia_api_request(
|
||||||
|
logger: logging.Logger,
|
||||||
|
metric: WikimediaApiRequestMetric,
|
||||||
|
) -> None:
|
||||||
|
"""Write one Wikimedia API request metric as a JSONL log line."""
|
||||||
|
record = build_log_record(metric)
|
||||||
|
logger.info(json.dumps(record, separators=(",", ":"), sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
class WikimediaRequestTimer:
|
||||||
|
"""Context manager for timing and logging a Wikimedia API request."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: WikimediaApiLogConfig,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
request_params: Mapping[str, object] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.method = method
|
||||||
|
self.url = url
|
||||||
|
self.request_params = request_params
|
||||||
|
self.started = 0.0
|
||||||
|
self.logger = setup_wikimedia_api_logger(config.log_path)
|
||||||
|
|
||||||
|
def __enter__(self) -> "WikimediaRequestTimer":
|
||||||
|
"""Start timing a request."""
|
||||||
|
self.started = time.monotonic()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Log failed requests when an exception escapes."""
|
||||||
|
if exc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed_ms = int((time.monotonic() - self.started) * 1000)
|
||||||
|
|
||||||
|
log_wikimedia_api_request(
|
||||||
|
self.logger,
|
||||||
|
WikimediaApiRequestMetric(
|
||||||
|
tool=self.config.tool,
|
||||||
|
url=self.url,
|
||||||
|
method=self.method,
|
||||||
|
status_code=None,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
user_agent=self.config.user_agent,
|
||||||
|
request_params=self.request_params,
|
||||||
|
error=type(exc).__name__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def log_response(self, status_code: int, final_url: str | None = None) -> None:
|
||||||
|
"""Log a completed request."""
|
||||||
|
elapsed_ms = int((time.monotonic() - self.started) * 1000)
|
||||||
|
|
||||||
|
log_wikimedia_api_request(
|
||||||
|
self.logger,
|
||||||
|
WikimediaApiRequestMetric(
|
||||||
|
tool=self.config.tool,
|
||||||
|
url=final_url or self.url,
|
||||||
|
method=self.method,
|
||||||
|
status_code=status_code,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
user_agent=self.config.user_agent,
|
||||||
|
request_params=self.request_params,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def logged_request(
|
||||||
|
session: requests.sessions.Session,
|
||||||
|
config: WikimediaApiLogConfig,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Make a Wikimedia API request and log one JSONL metric line."""
|
||||||
|
request_params = kwargs.get("params") or kwargs.get("data")
|
||||||
|
if not isinstance(request_params, Mapping):
|
||||||
|
request_params = None
|
||||||
|
|
||||||
|
with WikimediaRequestTimer(
|
||||||
|
config, method.upper(), url, request_params=request_params
|
||||||
|
) as timer:
|
||||||
|
response = getattr(session, method.lower())(url, **kwargs)
|
||||||
|
final_url = getattr(response, "url", None)
|
||||||
|
timer.log_response(
|
||||||
|
response.status_code, final_url if isinstance(final_url, str) else None
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
@ -85,6 +85,12 @@
|
||||||
const redirectTo = {{ redirect_to | 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);
|
||||||
|
const cleanPageUrl = new URL(pageUrl);
|
||||||
|
cleanPageUrl.searchParams.delete('title');
|
||||||
|
cleanPageUrl.searchParams.delete('after');
|
||||||
|
cleanPageUrl.searchParams.delete('skip');
|
||||||
|
const storageKey = 'missinglink.skipped.' + linkTo;
|
||||||
|
const skippedTitles = [];
|
||||||
|
|
||||||
const elProgress = document.getElementById('search-progress');
|
const elProgress = document.getElementById('search-progress');
|
||||||
const elStatus = document.getElementById('search-status');
|
const elStatus = document.getElementById('search-status');
|
||||||
|
|
@ -101,8 +107,39 @@
|
||||||
if (elCount) elCount.textContent = elList.children.length;
|
if (elCount) elCount.textContent = elList.children.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadSkippedTitles() {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(storageKey) || '[]');
|
||||||
|
if (Array.isArray(stored)) {
|
||||||
|
for (const title of stored) rememberSkipped(title, false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { localStorage.removeItem(storageKey); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const title of {{ skipped_titles | tojson }}) rememberSkipped(title, false);
|
||||||
|
saveSkippedTitles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSkippedTitles() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(skippedTitles));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSkipped(title, save = true) {
|
||||||
|
if (!title || skippedTitles.includes(title)) return;
|
||||||
|
skippedTitles.push(title);
|
||||||
|
if (save) saveSkippedTitles();
|
||||||
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
|
loadSkippedTitles();
|
||||||
|
for (const hitTitle of skippedTitles) removeCandidate(hitTitle);
|
||||||
|
|
||||||
for (const hitTitle of hits) {
|
for (const hitTitle of hits) {
|
||||||
|
if (skippedTitles.includes(hitTitle)) continue;
|
||||||
|
|
||||||
elStatus.textContent = `Checking "${hitTitle}"…`;
|
elStatus.textContent = `Checking "${hitTitle}"…`;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
|
|
@ -116,7 +153,11 @@
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.valid) { removeCandidate(hitTitle); continue; }
|
if (!data.valid) {
|
||||||
|
rememberSkipped(hitTitle);
|
||||||
|
removeCandidate(hitTitle);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
elProgress.hidden = true;
|
elProgress.hidden = true;
|
||||||
|
|
||||||
|
|
@ -126,13 +167,15 @@
|
||||||
document.getElementById('diff-table').innerHTML = data.diff;
|
document.getElementById('diff-table').innerHTML = data.diff;
|
||||||
document.getElementById('hit-input').value = hitTitle;
|
document.getElementById('hit-input').value = hitTitle;
|
||||||
|
|
||||||
const skipUrl = new URL(pageUrl);
|
const elSkipLink = document.getElementById('skip-link');
|
||||||
skipUrl.searchParams.delete('title');
|
elSkipLink.href = cleanPageUrl.toString();
|
||||||
skipUrl.searchParams.set('after', hitTitle);
|
elSkipLink.onclick = function () {
|
||||||
document.getElementById('skip-link').href = skipUrl.toString();
|
rememberSkipped(hitTitle);
|
||||||
|
};
|
||||||
|
|
||||||
const currentUrl = new URL(pageUrl);
|
const currentUrl = new URL(pageUrl);
|
||||||
currentUrl.searchParams.delete('after');
|
currentUrl.searchParams.delete('after');
|
||||||
|
currentUrl.searchParams.delete('skip');
|
||||||
currentUrl.searchParams.set('title', hitTitle);
|
currentUrl.searchParams.set('title', hitTitle);
|
||||||
history.replaceState(null, '', currentUrl.toString());
|
history.replaceState(null, '', currentUrl.toString());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,44 @@
|
||||||
<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 %} – Missing Link</title>
|
<title>{% block title %}{% endblock %} – Missing Link</title>
|
||||||
|
<style>
|
||||||
|
.site-navbar__tools {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.site-navbar .container {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-navbar__tools {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-navbar__search {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-navbar__search .form-control {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% 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 site-navbar">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand fw-semibold" href="{{ url_for('index') }}">Missing 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 site-navbar__tools">
|
||||||
<form class="d-flex" action="{{ url_for('index') }}">
|
<form class="d-flex site-navbar__search" 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">
|
||||||
<button class="btn btn-outline-light btn-sm" type="submit">Go</button>
|
<button class="btn btn-outline-light btn-sm" type="submit">Go</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
<h4 class="alert-heading">Something went wrong</h4>
|
<h4 class="alert-heading">Something went wrong</h4>
|
||||||
<p class="mb-0"><code>{{ message | e | replace('\n', '<br>\n') | safe }}</code></p>
|
<code class="d-block mb-0" style="white-space: pre-line">{{ message }}</code>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-sm">Back to home</a>
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary btn-sm">Back to home</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
16
test_api.py
16
test_api.py
|
|
@ -3,6 +3,7 @@ from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from simplejson.scanner import JSONDecodeError
|
from simplejson.scanner import JSONDecodeError
|
||||||
|
|
||||||
|
import web_view
|
||||||
from add_links import api
|
from add_links import api
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,12 +20,25 @@ class ApiGetTests(unittest.TestCase):
|
||||||
session = Mock()
|
session = Mock()
|
||||||
session.get.return_value = response
|
session.get.return_value = response
|
||||||
|
|
||||||
with patch("add_links.api._get_active_session", return_value=session):
|
with patch("add_links.api._get_active_session", return_value=(session, "anon")):
|
||||||
with self.assertRaises(api.MediawikiError) as ctx:
|
with self.assertRaises(api.MediawikiError) as ctx:
|
||||||
api.api_get({"action": "query"})
|
api.api_get({"action": "query"})
|
||||||
|
|
||||||
self.assertEqual(str(ctx.exception), response.text)
|
self.assertEqual(str(ctx.exception), response.text)
|
||||||
|
|
||||||
|
def test_active_session_uses_logged_in_request_session(self) -> None:
|
||||||
|
oauth_session = Mock()
|
||||||
|
|
||||||
|
with web_view.app.test_request_context("/"):
|
||||||
|
with patch(
|
||||||
|
"add_links.mediawiki_oauth.get_oauth_session",
|
||||||
|
return_value=oauth_session,
|
||||||
|
):
|
||||||
|
session, auth_mode = api._get_active_session()
|
||||||
|
|
||||||
|
self.assertIs(session, oauth_session)
|
||||||
|
self.assertEqual(auth_mode, "oauth")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,26 @@ class FindLinkInContentTests(unittest.TestCase):
|
||||||
self.assertEqual(replacement, "electoral fraud|ballot stuffing")
|
self.assertEqual(replacement, "electoral fraud|ballot stuffing")
|
||||||
self.assertEqual(replaced_text, "ballot stuffing")
|
self.assertEqual(replaced_text, "ballot stuffing")
|
||||||
|
|
||||||
|
def test_italic_text_inside_existing_link_does_not_split_link(self) -> None:
|
||||||
|
content = (
|
||||||
|
'For instance, "[[capitalism]]" is the name for the '
|
||||||
|
"[[Capitalist mode of production (Marxist theory)|capitalist "
|
||||||
|
"''mode of production'']] in which the means of production"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_content, replacement, replaced_text = find_link_in_content(
|
||||||
|
"capitalist mode of production", content, "Means of production"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
new_content,
|
||||||
|
'For instance, "[[capitalism]]" is the name for the '
|
||||||
|
"[[Capitalist mode of production (Marxist theory)|capitalist "
|
||||||
|
"''mode of production'']] in which the [[means of production]]",
|
||||||
|
)
|
||||||
|
self.assertEqual(replacement, "means of production")
|
||||||
|
self.assertEqual(replaced_text, "means of production")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
46
test_mediawiki_api.py
Normal file
46
test_mediawiki_api.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from add_links import mediawiki_api
|
||||||
|
|
||||||
|
|
||||||
|
class EditPageTests(unittest.TestCase):
|
||||||
|
def test_edit_page_raises_api_error_with_mediawiki_message(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"add_links.mediawiki_api.call",
|
||||||
|
return_value={"error": {"code": "badtoken", "info": "Invalid CSRF token."}},
|
||||||
|
):
|
||||||
|
with self.assertRaises(mediawiki_api.APIError) as ctx:
|
||||||
|
mediawiki_api.edit_page(
|
||||||
|
pageid=1,
|
||||||
|
section=0,
|
||||||
|
text="text",
|
||||||
|
summary="summary",
|
||||||
|
baserevid="123",
|
||||||
|
token="bad",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(str(ctx.exception), "Invalid CSRF token.")
|
||||||
|
|
||||||
|
def test_edit_page_raises_api_error_for_unexpected_response(self) -> None:
|
||||||
|
response = {"servedby": "mw-api-ext.eqiad.main"}
|
||||||
|
|
||||||
|
with patch("add_links.mediawiki_api.call", return_value=response):
|
||||||
|
with self.assertRaises(mediawiki_api.APIError) as ctx:
|
||||||
|
mediawiki_api.edit_page(
|
||||||
|
pageid=1,
|
||||||
|
section=0,
|
||||||
|
text="text",
|
||||||
|
summary="summary",
|
||||||
|
baserevid="123",
|
||||||
|
token="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
str(ctx.exception),
|
||||||
|
"Unexpected MediaWiki API response: {'servedby': 'mw-api-ext.eqiad.main'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
225
test_web_view.py
Normal file
225
test_web_view.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import web_view
|
||||||
|
|
||||||
|
|
||||||
|
def hit(title: str) -> web_view.Hit:
|
||||||
|
return {
|
||||||
|
"ns": 0,
|
||||||
|
"title": title,
|
||||||
|
"pageid": 1,
|
||||||
|
"size": 1,
|
||||||
|
"wordcount": 1,
|
||||||
|
"snippet": "",
|
||||||
|
"timestamp": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlePageSkipTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
web_view.app.config["TESTING"] = True
|
||||||
|
self.client = web_view.app.test_client()
|
||||||
|
|
||||||
|
def test_session_skips_are_left_out_of_candidates(self) -> None:
|
||||||
|
hits = [
|
||||||
|
hit("Mass racial violence in the United States"),
|
||||||
|
hit("National Register of Historic Places listings in Brookhaven (town), New York"),
|
||||||
|
hit("Useful candidate"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.client.session_transaction() as session:
|
||||||
|
session["skipped"] = {
|
||||||
|
"African-American neighborhood": [
|
||||||
|
"Mass racial violence in the United States",
|
||||||
|
"National Register of Historic Places listings in Brookhaven (town), New York",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("web_view.mediawiki_oauth.get_username", return_value="Test user"),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=object()),
|
||||||
|
patch("web_view.api.get_wiki_info", return_value=None),
|
||||||
|
patch("web_view.search_count", return_value=3),
|
||||||
|
patch("web_view.search_count_with_link", return_value=0),
|
||||||
|
patch("web_view.search_no_link", return_value=(3, hits)),
|
||||||
|
):
|
||||||
|
response = self.client.get("/link/African-American_neighborhood")
|
||||||
|
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn(
|
||||||
|
'data-title="Mass racial violence in the United States"', body
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
'data-title="National Register of Historic Places listings in Brookhaven (town), New York"',
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
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.mediawiki_oauth.get_username", return_value="Test user"),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=object()),
|
||||||
|
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.mediawiki_oauth.get_username", return_value="Test user"),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=object()),
|
||||||
|
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.mediawiki_oauth.get_username", return_value="Test user"),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=object()),
|
||||||
|
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 LoginRequiredTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
web_view.app.config["TESTING"] = True
|
||||||
|
self.client = web_view.app.test_client()
|
||||||
|
|
||||||
|
def test_article_page_redirects_to_login_before_api_calls(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("web_view.mediawiki_oauth.get_username", return_value=None),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=None),
|
||||||
|
patch("web_view.api.get_wiki_info") as get_wiki_info,
|
||||||
|
):
|
||||||
|
response = self.client.get("/link/DVD_documentary")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/oauth/start", response.location)
|
||||||
|
get_wiki_info.assert_not_called()
|
||||||
|
|
||||||
|
def test_valid_hit_requires_login_before_diff_call(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("web_view.mediawiki_oauth.get_username", return_value=None),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=None),
|
||||||
|
patch("web_view.get_diff") as get_diff,
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/1/valid_hit",
|
||||||
|
query_string={"link_to": "Target", "link_from": "Candidate"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(response.get_json(), {"error": "login required"})
|
||||||
|
get_diff.assert_not_called()
|
||||||
|
|
||||||
|
def test_hits_api_requires_login_before_search(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("web_view.mediawiki_oauth.get_username", return_value=None),
|
||||||
|
patch("web_view.mediawiki_oauth.get_oauth_session", return_value=None),
|
||||||
|
patch("web_view.core.do_search") as do_search,
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/1/hits", query_string={"title": "Target"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(response.get_json(), {"error": "login required"})
|
||||||
|
do_search.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ProtectedCandidateTests(unittest.TestCase):
|
||||||
|
def test_edit_protected_hits_are_filtered_out(self) -> None:
|
||||||
|
hits = [hit("Editable"), hit("Protected")]
|
||||||
|
hits[0]["pageid"] = 10
|
||||||
|
hits[1]["pageid"] = 20
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"web_view.get_page_info_by_id",
|
||||||
|
return_value={
|
||||||
|
10: {"pageid": 10, "title": "Editable", "protection": []},
|
||||||
|
20: {
|
||||||
|
"pageid": 20,
|
||||||
|
"title": "Protected",
|
||||||
|
"protection": [
|
||||||
|
{"type": "edit", "level": "sysop", "expiry": "infinity"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
filtered = web_view.filter_edit_protected_hits(hits)
|
||||||
|
|
||||||
|
self.assertEqual([item["title"] for item in filtered], ["Editable"])
|
||||||
|
|
||||||
|
def test_search_no_link_filters_edit_protected_results(self) -> None:
|
||||||
|
hits = [hit("Editable"), hit("Protected")]
|
||||||
|
hits[0]["pageid"] = 10
|
||||||
|
hits[1]["pageid"] = 20
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"web_view.run_search",
|
||||||
|
return_value={"searchinfo": {"totalhits": 2}, "search": hits},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"web_view.get_page_info_by_id",
|
||||||
|
return_value={
|
||||||
|
10: {"pageid": 10, "title": "Editable", "protection": []},
|
||||||
|
20: {
|
||||||
|
"pageid": 20,
|
||||||
|
"title": "Protected",
|
||||||
|
"protection": [
|
||||||
|
{"type": "edit", "level": "sysop", "expiry": "infinity"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
totalhits, filtered = web_view.search_no_link("Target")
|
||||||
|
|
||||||
|
self.assertEqual(totalhits, 2)
|
||||||
|
self.assertEqual([item["title"] for item in filtered], ["Editable"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
146
web_view.py
146
web_view.py
|
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import itertools
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -37,6 +36,14 @@ class Hit(typing.TypedDict):
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class PageInfo(typing.TypedDict, total=False):
|
||||||
|
"""Wikipedia page metadata."""
|
||||||
|
|
||||||
|
pageid: int
|
||||||
|
title: str
|
||||||
|
protection: list[dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
def render_error(message: str) -> str:
|
def render_error(message: str) -> str:
|
||||||
"""Render shared error page."""
|
"""Render shared error page."""
|
||||||
return flask.render_template("error.html", message=message)
|
return flask.render_template("error.html", message=message)
|
||||||
|
|
@ -97,6 +104,28 @@ def article_url(title: str) -> str:
|
||||||
return flask.url_for("article_page", url_title=title.replace(" ", "_"))
|
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:
|
def get_hit_count(q: str) -> int:
|
||||||
"""Search Wikipedia and return hit count."""
|
"""Search Wikipedia and return hit count."""
|
||||||
return typing.cast(int, run_search(q, limit=0)["searchinfo"]["totalhits"])
|
return typing.cast(int, run_search(q, limit=0)["searchinfo"]["totalhits"])
|
||||||
|
|
@ -115,13 +144,60 @@ def search_count_with_link(q: str, redirect_to: str | None = None) -> int:
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def chunks(items: list[int], size: int) -> typing.Iterator[list[int]]:
|
||||||
|
"""Yield fixed-size chunks from a list."""
|
||||||
|
for offset in range(0, len(items), size):
|
||||||
|
yield items[offset : offset + size]
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_info_by_id(pageids: list[int]) -> dict[int, PageInfo]:
|
||||||
|
"""Fetch metadata for pages by page ID."""
|
||||||
|
if not pageids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
pages: dict[int, PageInfo] = {}
|
||||||
|
for pageid_chunk in chunks(pageids, 50):
|
||||||
|
ret = api.api_get(
|
||||||
|
{
|
||||||
|
"prop": "info",
|
||||||
|
"inprop": "protection",
|
||||||
|
"pageids": "|".join(str(pageid) for pageid in pageid_chunk),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for page in ret["query"]["pages"]:
|
||||||
|
pageid = typing.cast(int, page["pageid"])
|
||||||
|
pages[pageid] = typing.cast(PageInfo, page)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def is_edit_protected(page: PageInfo | None) -> bool:
|
||||||
|
"""Check if a page has edit protection."""
|
||||||
|
if not page:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
protection.get("type") == "edit" or protection.get("action") == "edit"
|
||||||
|
for protection in page.get("protection", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_edit_protected_hits(hits: list[Hit]) -> list[Hit]:
|
||||||
|
"""Remove candidates that cannot be edited because of page protection."""
|
||||||
|
page_info = get_page_info_by_id([hit["pageid"] for hit in hits])
|
||||||
|
return [
|
||||||
|
hit for hit in hits if not is_edit_protected(page_info.get(hit["pageid"]))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def search_no_link(q: str, redirect_to: str | None = None) -> 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."""
|
||||||
exclude = f' -linksto:"{q}"'
|
exclude = f' -linksto:"{q}"'
|
||||||
if redirect_to:
|
if redirect_to:
|
||||||
exclude += f' -linksto:"{redirect_to}"'
|
exclude += f' -linksto:"{redirect_to}"'
|
||||||
query = run_search(article_title_to_search_query(q) + exclude, "max")
|
query = run_search(article_title_to_search_query(q) + exclude, "max")
|
||||||
return (query["searchinfo"]["totalhits"], query["search"])
|
return (
|
||||||
|
query["searchinfo"]["totalhits"],
|
||||||
|
filter_edit_protected_hits(query["search"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
|
|
@ -131,6 +207,22 @@ def global_user() -> None:
|
||||||
flask.g.oauth_session = mediawiki_oauth.get_oauth_session()
|
flask.g.oauth_session = mediawiki_oauth.get_oauth_session()
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in() -> bool:
|
||||||
|
"""Return true when the current request has a Wikipedia OAuth session."""
|
||||||
|
return flask.g.oauth_session is not None
|
||||||
|
|
||||||
|
|
||||||
|
def login_redirect() -> Response:
|
||||||
|
"""Redirect the current browser request through Wikipedia OAuth."""
|
||||||
|
next_url = flask.request.full_path.rstrip("?")
|
||||||
|
return flask.redirect(flask.url_for("start_oauth", next=next_url))
|
||||||
|
|
||||||
|
|
||||||
|
def login_required_json() -> tuple[werkzeug.wrappers.response.Response, int]:
|
||||||
|
"""Return a JSON login-required response without making MediaWiki API calls."""
|
||||||
|
return flask.jsonify(error="login required"), 401
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index() -> str | Response:
|
def index() -> str | Response:
|
||||||
"""Index page."""
|
"""Index page."""
|
||||||
|
|
@ -317,6 +409,9 @@ def match_type(q: str, snippet: str) -> str | None:
|
||||||
|
|
||||||
def _record_skip(from_title: str, hit_title: str) -> None:
|
def _record_skip(from_title: str, hit_title: str) -> None:
|
||||||
"""Record that a candidate was skipped or saved for this article."""
|
"""Record that a candidate was skipped or saved for this article."""
|
||||||
|
hit_title = hit_title.strip()
|
||||||
|
if not hit_title:
|
||||||
|
return
|
||||||
skipped: dict[str, list[str]] = flask.session.get("skipped", {})
|
skipped: dict[str, list[str]] = flask.session.get("skipped", {})
|
||||||
article_skipped = skipped.get(from_title, [])
|
article_skipped = skipped.get(from_title, [])
|
||||||
if hit_title not in article_skipped:
|
if hit_title not in article_skipped:
|
||||||
|
|
@ -327,6 +422,10 @@ def _record_skip(from_title: str, hit_title: str) -> None:
|
||||||
|
|
||||||
def handle_post(url_title: str) -> Response:
|
def handle_post(url_title: str) -> Response:
|
||||||
"""Handle POST request."""
|
"""Handle POST request."""
|
||||||
|
if not is_logged_in():
|
||||||
|
next_url = flask.url_for("article_page", url_title=url_title)
|
||||||
|
return flask.redirect(flask.url_for("start_oauth", next=next_url))
|
||||||
|
|
||||||
from_title = url_title.replace("_", " ").strip()
|
from_title = url_title.replace("_", " ").strip()
|
||||||
hit_title = flask.request.form["hit"]
|
hit_title = flask.request.form["hit"]
|
||||||
try:
|
try:
|
||||||
|
|
@ -334,6 +433,8 @@ def handle_post(url_title: str) -> Response:
|
||||||
except mediawiki_oauth.LoginNeeded:
|
except mediawiki_oauth.LoginNeeded:
|
||||||
next_url = flask.url_for("article_page", url_title=url_title, title=hit_title)
|
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))
|
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:
|
except (mediawiki_api.APIError, api.MediawikiError) as e:
|
||||||
return flask.make_response(
|
return flask.make_response(
|
||||||
render_mediawiki_error(e, prefix="Save failed"), 502
|
render_mediawiki_error(e, prefix="Save failed"), 502
|
||||||
|
|
@ -355,6 +456,9 @@ def article_page(url_title: str) -> str | Response:
|
||||||
if flask.request.method == "POST":
|
if flask.request.method == "POST":
|
||||||
return handle_post(url_title)
|
return handle_post(url_title)
|
||||||
|
|
||||||
|
if not is_logged_in():
|
||||||
|
return login_redirect()
|
||||||
|
|
||||||
from_title = url_title.replace("_", " ").strip()
|
from_title = url_title.replace("_", " ").strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -371,10 +475,8 @@ def article_page(url_title: str) -> str | Response:
|
||||||
render_mediawiki_error(e), 502
|
render_mediawiki_error(e), 502
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter out candidates already processed this session
|
article_skipped = flask.session.get("skipped", {}).get(from_title, [])
|
||||||
session_skipped: set[str] = set(
|
session_skipped: set[str] = set(article_skipped)
|
||||||
flask.session.get("skipped", {}).get(from_title, [])
|
|
||||||
)
|
|
||||||
|
|
||||||
# If a specific candidate was requested, move it to the front
|
# If a specific candidate was requested, move it to the front
|
||||||
title_param = flask.request.args.get("title")
|
title_param = flask.request.args.get("title")
|
||||||
|
|
@ -382,14 +484,12 @@ def article_page(url_title: str) -> str | Response:
|
||||||
hits = [h for h in hits if h["title"] == title_param] + \
|
hits = [h for h in hits if h["title"] == title_param] + \
|
||||||
[h for h in hits if h["title"] != title_param]
|
[h for h in hits if h["title"] != title_param]
|
||||||
|
|
||||||
# Record and apply explicit skip-past
|
hits = [
|
||||||
after = flask.request.args.get("after")
|
h
|
||||||
if after:
|
for h in hits
|
||||||
_record_skip(from_title, after)
|
if h["title"] not in session_skipped
|
||||||
session_skipped.add(after)
|
and not is_self_link_candidate(h["title"], from_title, redirect_to)
|
||||||
|
]
|
||||||
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)]
|
|
||||||
|
|
||||||
if not hits:
|
if not hits:
|
||||||
return flask.render_template("all_done.html")
|
return flask.render_template("all_done.html")
|
||||||
|
|
@ -404,19 +504,23 @@ def article_page(url_title: str) -> str | Response:
|
||||||
with_link=with_link,
|
with_link=with_link,
|
||||||
hits=hits,
|
hits=hits,
|
||||||
url_title=url_title,
|
url_title=url_title,
|
||||||
|
skipped_titles=article_skipped,
|
||||||
saves_this_session=saves_this_session,
|
saves_this_session=saves_this_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def do_save(title: str, hit_title: str) -> str:
|
def do_save(title: str, hit_title: str) -> str:
|
||||||
"""Update page on Wikipedia."""
|
"""Update page on Wikipedia."""
|
||||||
token = mediawiki_oauth.get_token()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redirect_to = api.get_wiki_info(title)
|
redirect_to = api.get_wiki_info(title)
|
||||||
except (api.MissingPage, api.MultipleRedirects, api.MediawikiError):
|
except (api.MissingPage, api.MultipleRedirects, api.MediawikiError):
|
||||||
redirect_to = None
|
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)
|
found = get_match(title, hit_title, redirect_to)
|
||||||
|
|
||||||
summary = (
|
summary = (
|
||||||
|
|
@ -444,6 +548,9 @@ def save_done() -> str:
|
||||||
@app.route("/api/1/hits")
|
@app.route("/api/1/hits")
|
||||||
def api_hits() -> werkzeug.wrappers.response.Response:
|
def api_hits() -> werkzeug.wrappers.response.Response:
|
||||||
"""Return candidates for the given article title."""
|
"""Return candidates for the given article title."""
|
||||||
|
if not is_logged_in():
|
||||||
|
return login_required_json()
|
||||||
|
|
||||||
title = flask.request.args.get("title")
|
title = flask.request.args.get("title")
|
||||||
assert title
|
assert title
|
||||||
ret = core.do_search(title)
|
ret = core.do_search(title)
|
||||||
|
|
@ -456,10 +563,17 @@ def api_hits() -> werkzeug.wrappers.response.Response:
|
||||||
@app.route("/api/1/valid_hit")
|
@app.route("/api/1/valid_hit")
|
||||||
def api_valid_hit() -> werkzeug.wrappers.response.Response:
|
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."""
|
||||||
|
if not is_logged_in():
|
||||||
|
return login_required_json()
|
||||||
|
|
||||||
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
|
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:
|
try:
|
||||||
found = get_diff(link_to, link_from, redirect_to)
|
found = get_diff(link_to, link_from, redirect_to)
|
||||||
except NoMatch:
|
except NoMatch:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue