From 92c49139fdc2360b22023fcdde2a09831e66c0f1 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 28 Jan 2026 09:05:41 +0100 Subject: [PATCH 1/6] Testing PR --- .github/workflows/test_links.yml | 67 +++++++++++++++++ docs.bzl | 13 ++++ scripts/link_parser.py | 120 +++++++++++++++++++++++++++++++ src/incremental.py | 2 + 4 files changed, 202 insertions(+) create mode 100644 .github/workflows/test_links.yml create mode 100644 scripts/link_parser.py diff --git a/.github/workflows/test_links.yml b/.github/workflows/test_links.yml new file mode 100644 index 000000000..efc32261b --- /dev/null +++ b/.github/workflows/test_links.yml @@ -0,0 +1,67 @@ +name: Link Check and Automated Issue + +on: + workflow_dispatch: + +jobs: + check-links: + runs-on: ubuntu-latest + outputs: + should_create_issue: ${{ steps.detect.outputs.issue_needed }} + steps: + - name: Checkout repository + uses: actions/checkout@v4.2.2 + + # Run your link checker and generate log + - name: Run LinkChecker + run: | + bazel run //:link_check > linkcheck_output.txt + # or: sphinx-build ... 2>&1 | tee linkcheck_output.txt + + # Run your Python script to parse the linkcheck log and generate issue body + - name: Parse broken links and generate issue body + run: | + pip install --user dataclasses # only for older Python, likely not needed 3.7+ + python3 scripts/link_parser.py linkcheck_output.txt + + # Check if issue_body.md exists and is not empty + - name: Check for issues to report + id: detect + run: | + if [ -s issue_body.md ]; then + echo "issue_needed=true" >> $GITHUB_OUTPUT + else + echo "issue_needed=false" >> $GITHUB_OUTPUT + fi + + # Upload issue body artifact if present + - name: Upload issue body + if: steps.detect.outputs.issue_needed == 'true' + uses: actions/upload-artifact@v4 + with: + name: issue-body + path: issue_body.md + + create-issue: + needs: check-links + if: needs.check-links.outputs.should_create_issue == 'true' + runs-on: ubuntu-latest + steps: + - name: Download issue body artifact + uses: actions/download-artifact@v4 + with: + name: issue-body + + - name: Create GitHub issue from findings + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const body = fs.readFileSync('issue_body.md', 'utf-8'); + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: "Automated Issue: Broken Documentation Links", + body, + }); diff --git a/docs.bzl b/docs.bzl index 00f1c676c..9860bd664 100644 --- a/docs.bzl +++ b/docs.bzl @@ -128,6 +128,19 @@ def docs(source_dir = "docs", data = [], deps = []): }, ) + py_binary( + name = "link_check", + tags = ["cli_help=Verify Links inside Documentation:\nbazel run //:link_check\n (Note: this could take a long time)"], + srcs = ["@score_docs_as_code//src:incremental.py"], + data = data, + deps = deps, + env = { + "SOURCE_DIRECTORY": source_dir, + "DATA": str(data), + "ACTION": "linkcheck", + }, + ) + py_binary( name = "docs_check", tags = ["cli_help=Verify documentation:\nbazel run //:docs_check"], diff --git a/scripts/link_parser.py b/scripts/link_parser.py new file mode 100644 index 000000000..c710881c5 --- /dev/null +++ b/scripts/link_parser.py @@ -0,0 +1,120 @@ +""" +EXAMPLE LOG INPUT: + +(how-to/write_docs: line 7) ok https://docutils.sourceforge.io/rst.html +(internals/extensions/extension_guide: line 136) ok https://docs.pytest.org/en/stable/ +(internals/extensions/source_code_linker: line 221) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_common_attr_id_scheme - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html +(internals/extensions/source_code_linker: line 224) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_dd_link_source_code_link - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html +(internals/extensions/source_code_linker: line 221) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_common_attr_status - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html +(concepts/bidirectional_traceability: line 29) ok https://eclipse-score.github.io +(internals/extensions/extension_guide: line 128) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_draw_uml_funcs +(internals/extensions/extension_guide: line 127) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_metamodel +(internals/extensions/extension_guide: line 122) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_source_code_linker/ +(internals/benchmark_results: line 18) ok https://github.com/eclipse-score/process_description +(internals/extensions/extension_guide: line 124) ok https://github.com/eclipse-score/tooling/blob/main/python_basics/score_pytest/README.md +(internals/extensions/sync_toml: line 6) ok https://needs-config-writer.useblocks.com/ +(internals/extensions/source_code_linker: line 67) ok https://github.com/eclipse-score/tooling/tree/main/python_basics/score_pytest +(concepts/bidirectional_traceability: line 47) ok https://sphinx-collections.readthedocs.io/en/latest/ +(how-to/other_modules: line 63) ok https://sphinx-needs.readthedocs.io/en/latest/ +(internals/extensions/extension_guide: line 47) broken https://github.com/useblocks/sphinx-needs/blob/master/docs/contributing.rst#structure-of-the-extensions-logic - Anchor 'structure-of-the-extensions-logic' not found +(internals/requirements/capabilities: line 5) redirect https://sphinx-needs.readthedocs.io/ - with Found to https://sphinx-needs.readthedocs.io/en/stable/ +( how-to/faq: line 37) ok https://ubcode.useblocks.com +(internals/extensions/sync_toml: line 9) ok https://ubcode.useblocks.com/ubc/introduction.html +(how-to/write_docs: line 4) ok https://www.sphinx-doc.org/en/master/ +(internals/requirements/capabilities: line 5) redirect https://www.sphinx-doc.org/ - with Found to https://www.sphinx-doc.org/en/master/ +(internals/extensions/extension_guide: line 135) ok https://www.sphinx-doc.org/en/master/development/tutorials/index.html +(internals/extensions/extension_guide: line 133) ok https://www.sphinx-doc.org/en/master/extdev/testing.html#module-sphinx.testing +(internals/extensions/extension_guide: line 132) redirect https://www.sphinx-doc.org/en - with Found to https://www.sphinx-doc.org/en/master/ +(internals/extensions/extension_guide: line 65) ok https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx.application.Sphinx.add_config_value +(internals/extensions/extension_guide: line 46) ok https://www.sphinx-doc.org/en/master/extdev/event_callbacks.html#core-events-overview +(internals/extensions/extension_guide: line 45) ok https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx.application.Sphinx.connect +""" +import argparse +import sys +from dataclasses import dataclass + +PARSING_STATUSES = ["broken"] + +@dataclass +class BrokenLink: + location: str + line_nr: str + url: str + status: str + reasoning: str + +# Make me a function that parses the above string and returns a list with all links that are broken or don't work and where they are in the documentation. +# The string above is just an example. The actuall string will need to parse std out from a sphinx execution, or read a txt file that contains this format. +def parse_broken_links(log: str) -> list[BrokenLink]: + broken_links: list[BrokenLink] = [] + lines = log.strip().split("\n") + + for line in lines: + parts = line.split(") ") + if len(parts) < 2: + continue + + location_part = parts[0].replace("(", "").strip() + location= location_part.split(":")[0].strip() + line_nr = location_part.split("line")[-1].strip() + status_and_url_part = parts[1] + + if not any(status in status_and_url_part for status in PARSING_STATUSES): + continue + status_and_url = status_and_url_part.split(" - ") + # status = list(filter(None,status_and_url.split(' '))) + if len(status_and_url) < 2: + continue + status, url = status_and_url[0].strip().split() + reasoning = status_and_url[1].strip() + + broken_links.append(BrokenLink( + location=location, + line_nr=line_nr, + url=url, + status=status, + reasoning=reasoning + )) + + return broken_links + +# make me a function that takes the dictionary of parse_broken_links and puts them into a nice markdown table. +def generate_markdown_table(broken_links: list[BrokenLink]) -> str: + table = "| Location | Line Number | URL | Status | Reasoning |\n" + table += "|----------|-------------|-----|--------|-----------|\n" + + for link in broken_links: + table += f"| {link.location} | {link.line_nr} | {link.url} | {link.status} | {link.reasoning} |\n" + + return table + + +def generate_issue_body(broken_links: list[BrokenLink]) -> str: + markdown_table = generate_markdown_table(broken_links) + issue_body = f""" +# Broken Links Report +The following broken links were detected in the documentation: +{markdown_table} +Please investigate and fix these issues to ensure all links are functional. +Thank you! +""" + return issue_body + + +if __name__ == "__main__": + argparse = argparse.ArgumentParser(description="Parse broken links from Sphinx log and generate issue body.") + argparse.add_argument("logfile", type=str, help="Path to the Sphinx log file.") + args = argparse.parse_args() + with open(args.logfile, "r") as f: + log_content = f.read() + broken_links = parse_broken_links(log_content) + if not broken_links: + # Nothing broken found, can exit early + sys.exit(0) + issue_body = generate_issue_body(broken_links) + if broken_links: + with open("issue_body.md", "w") as out: + out.write(issue_body) + + + diff --git a/src/incremental.py b/src/incremental.py index fbabf8b1a..1c3816229 100644 --- a/src/incremental.py +++ b/src/incremental.py @@ -109,6 +109,8 @@ def get_env(name: str) -> str: builder = "html" elif action == "check": builder = "needs" + elif action == "linkcheck": + builder = "linkcheck" else: raise ValueError(f"Unknown action: {action}") From c2fdf3dc49aa7c7884b523f9874435c1746e47a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20S=C3=B6ren=20Pollak?= Date: Wed, 28 Jan 2026 09:13:11 +0100 Subject: [PATCH 2/6] Update test_links.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Sören Pollak --- .github/workflows/test_links.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_links.yml b/.github/workflows/test_links.yml index efc32261b..b3ed4ef57 100644 --- a/.github/workflows/test_links.yml +++ b/.github/workflows/test_links.yml @@ -16,7 +16,7 @@ jobs: - name: Run LinkChecker run: | bazel run //:link_check > linkcheck_output.txt - # or: sphinx-build ... 2>&1 | tee linkcheck_output.txt + continue-on-error: true # Run your Python script to parse the linkcheck log and generate issue body - name: Parse broken links and generate issue body From a395f2a0b0157e1c5108f416be2546db0295d4b7 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 28 Jan 2026 09:20:55 +0100 Subject: [PATCH 3/6] Clean Ansi codes --- scripts/link_parser.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/link_parser.py b/scripts/link_parser.py index c710881c5..38f366295 100644 --- a/scripts/link_parser.py +++ b/scripts/link_parser.py @@ -31,6 +31,7 @@ """ import argparse import sys +import re from dataclasses import dataclass PARSING_STATUSES = ["broken"] @@ -100,13 +101,19 @@ def generate_issue_body(broken_links: list[BrokenLink]) -> str: """ return issue_body +def strip_ansi_codes(text: str) -> str: + """Remove ANSI escape sequences from text""" + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + return ansi_escape.sub("", text) + if __name__ == "__main__": argparse = argparse.ArgumentParser(description="Parse broken links from Sphinx log and generate issue body.") argparse.add_argument("logfile", type=str, help="Path to the Sphinx log file.") args = argparse.parse_args() with open(args.logfile, "r") as f: - log_content = f.read() + log_content_raw = f.read() + log_content = strip_ansi_codes(log_content_raw) broken_links = parse_broken_links(log_content) if not broken_links: # Nothing broken found, can exit early From 92cff6bfd8bccc003629d8780f12a01f4312a7f3 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 28 Jan 2026 09:59:43 +0100 Subject: [PATCH 4/6] Formatting --- scripts/link_parser.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/scripts/link_parser.py b/scripts/link_parser.py index 38f366295..527545e64 100644 --- a/scripts/link_parser.py +++ b/scripts/link_parser.py @@ -29,6 +29,7 @@ (internals/extensions/extension_guide: line 46) ok https://www.sphinx-doc.org/en/master/extdev/event_callbacks.html#core-events-overview (internals/extensions/extension_guide: line 45) ok https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx.application.Sphinx.connect """ + import argparse import sys import re @@ -36,6 +37,7 @@ PARSING_STATUSES = ["broken"] + @dataclass class BrokenLink: location: str @@ -44,6 +46,7 @@ class BrokenLink: status: str reasoning: str + # Make me a function that parses the above string and returns a list with all links that are broken or don't work and where they are in the documentation. # The string above is just an example. The actuall string will need to parse std out from a sphinx execution, or read a txt file that contains this format. def parse_broken_links(log: str) -> list[BrokenLink]: @@ -56,12 +59,12 @@ def parse_broken_links(log: str) -> list[BrokenLink]: continue location_part = parts[0].replace("(", "").strip() - location= location_part.split(":")[0].strip() + location = location_part.split(":")[0].strip() line_nr = location_part.split("line")[-1].strip() status_and_url_part = parts[1] if not any(status in status_and_url_part for status in PARSING_STATUSES): - continue + continue status_and_url = status_and_url_part.split(" - ") # status = list(filter(None,status_and_url.split(' '))) if len(status_and_url) < 2: @@ -69,17 +72,20 @@ def parse_broken_links(log: str) -> list[BrokenLink]: status, url = status_and_url[0].strip().split() reasoning = status_and_url[1].strip() - broken_links.append(BrokenLink( - location=location, - line_nr=line_nr, - url=url, - status=status, - reasoning=reasoning - )) + broken_links.append( + BrokenLink( + location=location, + line_nr=line_nr, + url=url, + status=status, + reasoning=reasoning, + ) + ) return broken_links -# make me a function that takes the dictionary of parse_broken_links and puts them into a nice markdown table. + +# make me a function that takes the dictionary of parse_broken_links and puts them into a nice markdown table. def generate_markdown_table(broken_links: list[BrokenLink]) -> str: table = "| Location | Line Number | URL | Status | Reasoning |\n" table += "|----------|-------------|-----|--------|-----------|\n" @@ -101,6 +107,7 @@ def generate_issue_body(broken_links: list[BrokenLink]) -> str: """ return issue_body + def strip_ansi_codes(text: str) -> str: """Remove ANSI escape sequences from text""" ansi_escape = re.compile(r"\x1b\[[0-9;]*m") @@ -108,7 +115,9 @@ def strip_ansi_codes(text: str) -> str: if __name__ == "__main__": - argparse = argparse.ArgumentParser(description="Parse broken links from Sphinx log and generate issue body.") + argparse = argparse.ArgumentParser( + description="Parse broken links from Sphinx log and generate issue body." + ) argparse.add_argument("logfile", type=str, help="Path to the Sphinx log file.") args = argparse.parse_args() with open(args.logfile, "r") as f: @@ -122,6 +131,3 @@ def strip_ansi_codes(text: str) -> str: if broken_links: with open("issue_body.md", "w") as out: out.write(issue_body) - - - From 66a83a268b5316d44b45f666eaa34bbbf1d6c920 Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 28 Jan 2026 10:05:36 +0100 Subject: [PATCH 5/6] Formating --- scripts/link_parser.py | 48 ++++++------------------------------------ 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/scripts/link_parser.py b/scripts/link_parser.py index 527545e64..a9109f3cb 100644 --- a/scripts/link_parser.py +++ b/scripts/link_parser.py @@ -1,38 +1,6 @@ -""" -EXAMPLE LOG INPUT: - -(how-to/write_docs: line 7) ok https://docutils.sourceforge.io/rst.html -(internals/extensions/extension_guide: line 136) ok https://docs.pytest.org/en/stable/ -(internals/extensions/source_code_linker: line 221) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_common_attr_id_scheme - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html -(internals/extensions/source_code_linker: line 224) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_dd_link_source_code_link - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html -(internals/extensions/source_code_linker: line 221) broken https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html#tool_req__docs_common_attr_status - 404 Client Error: Not Found for url: https://eclipse-score.github.io/docs-as-code/main/requirements/requirements.html -(concepts/bidirectional_traceability: line 29) ok https://eclipse-score.github.io -(internals/extensions/extension_guide: line 128) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_draw_uml_funcs -(internals/extensions/extension_guide: line 127) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_metamodel -(internals/extensions/extension_guide: line 122) ok https://github.com/eclipse-score/docs-as-code/tree/main/src/extensions/score_source_code_linker/ -(internals/benchmark_results: line 18) ok https://github.com/eclipse-score/process_description -(internals/extensions/extension_guide: line 124) ok https://github.com/eclipse-score/tooling/blob/main/python_basics/score_pytest/README.md -(internals/extensions/sync_toml: line 6) ok https://needs-config-writer.useblocks.com/ -(internals/extensions/source_code_linker: line 67) ok https://github.com/eclipse-score/tooling/tree/main/python_basics/score_pytest -(concepts/bidirectional_traceability: line 47) ok https://sphinx-collections.readthedocs.io/en/latest/ -(how-to/other_modules: line 63) ok https://sphinx-needs.readthedocs.io/en/latest/ -(internals/extensions/extension_guide: line 47) broken https://github.com/useblocks/sphinx-needs/blob/master/docs/contributing.rst#structure-of-the-extensions-logic - Anchor 'structure-of-the-extensions-logic' not found -(internals/requirements/capabilities: line 5) redirect https://sphinx-needs.readthedocs.io/ - with Found to https://sphinx-needs.readthedocs.io/en/stable/ -( how-to/faq: line 37) ok https://ubcode.useblocks.com -(internals/extensions/sync_toml: line 9) ok https://ubcode.useblocks.com/ubc/introduction.html -(how-to/write_docs: line 4) ok https://www.sphinx-doc.org/en/master/ -(internals/requirements/capabilities: line 5) redirect https://www.sphinx-doc.org/ - with Found to https://www.sphinx-doc.org/en/master/ -(internals/extensions/extension_guide: line 135) ok https://www.sphinx-doc.org/en/master/development/tutorials/index.html -(internals/extensions/extension_guide: line 133) ok https://www.sphinx-doc.org/en/master/extdev/testing.html#module-sphinx.testing -(internals/extensions/extension_guide: line 132) redirect https://www.sphinx-doc.org/en - with Found to https://www.sphinx-doc.org/en/master/ -(internals/extensions/extension_guide: line 65) ok https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx.application.Sphinx.add_config_value -(internals/extensions/extension_guide: line 46) ok https://www.sphinx-doc.org/en/master/extdev/event_callbacks.html#core-events-overview -(internals/extensions/extension_guide: line 45) ok https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx.application.Sphinx.connect -""" - import argparse -import sys import re +import sys from dataclasses import dataclass PARSING_STATUSES = ["broken"] @@ -46,9 +14,6 @@ class BrokenLink: status: str reasoning: str - -# Make me a function that parses the above string and returns a list with all links that are broken or don't work and where they are in the documentation. -# The string above is just an example. The actuall string will need to parse std out from a sphinx execution, or read a txt file that contains this format. def parse_broken_links(log: str) -> list[BrokenLink]: broken_links: list[BrokenLink] = [] lines = log.strip().split("\n") @@ -85,27 +50,28 @@ def parse_broken_links(log: str) -> list[BrokenLink]: return broken_links -# make me a function that takes the dictionary of parse_broken_links and puts them into a nice markdown table. def generate_markdown_table(broken_links: list[BrokenLink]) -> str: table = "| Location | Line Number | URL | Status | Reasoning |\n" table += "|----------|-------------|-----|--------|-----------|\n" for link in broken_links: - table += f"| {link.location} | {link.line_nr} | {link.url} | {link.status} | {link.reasoning} |\n" + table += ( + f"| {link.location} | {link.line_nr} | " + f"{link.url} | {link.status} | {link.reasoning} |\n" + ) return table def generate_issue_body(broken_links: list[BrokenLink]) -> str: markdown_table = generate_markdown_table(broken_links) - issue_body = f""" + return f""" # Broken Links Report The following broken links were detected in the documentation: {markdown_table} Please investigate and fix these issues to ensure all links are functional. Thank you! """ - return issue_body def strip_ansi_codes(text: str) -> str: @@ -120,7 +86,7 @@ def strip_ansi_codes(text: str) -> str: ) argparse.add_argument("logfile", type=str, help="Path to the Sphinx log file.") args = argparse.parse_args() - with open(args.logfile, "r") as f: + with open(args.logfile) as f: log_content_raw = f.read() log_content = strip_ansi_codes(log_content_raw) broken_links = parse_broken_links(log_content) From 6a8640815d5cb6efab6fd602c0e5c9687bffcfae Mon Sep 17 00:00:00 2001 From: MaximilianSoerenPollak Date: Wed, 28 Jan 2026 10:12:14 +0100 Subject: [PATCH 6/6] Fix yaml linter warnings --- .github/workflows/test_links.yml | 5 ++--- scripts/link_parser.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_links.yml b/.github/workflows/test_links.yml index b3ed4ef57..e8d382c33 100644 --- a/.github/workflows/test_links.yml +++ b/.github/workflows/test_links.yml @@ -21,7 +21,6 @@ jobs: # Run your Python script to parse the linkcheck log and generate issue body - name: Parse broken links and generate issue body run: | - pip install --user dataclasses # only for older Python, likely not needed 3.7+ python3 scripts/link_parser.py linkcheck_output.txt # Check if issue_body.md exists and is not empty @@ -29,9 +28,9 @@ jobs: id: detect run: | if [ -s issue_body.md ]; then - echo "issue_needed=true" >> $GITHUB_OUTPUT + echo "issue_needed=true" >> "$GITHUB_OUTPUT" else - echo "issue_needed=false" >> $GITHUB_OUTPUT + echo "issue_needed=false" >> "$GITHUB_OUTPUT" fi # Upload issue body artifact if present diff --git a/scripts/link_parser.py b/scripts/link_parser.py index a9109f3cb..7c20a5e55 100644 --- a/scripts/link_parser.py +++ b/scripts/link_parser.py @@ -14,6 +14,7 @@ class BrokenLink: status: str reasoning: str + def parse_broken_links(log: str) -> list[BrokenLink]: broken_links: list[BrokenLink] = [] lines = log.strip().split("\n")