@@ -906,7 +906,7 @@ def cmd_report(args):
906906 results_doc = json .load (fh )
907907 state = load_state_file (args .state )
908908
909- dead , suspect , blocked = [], [], []
909+ dead , suspect , blocked , moved = [], [], [], []
910910 for r in results_doc ["results" ]:
911911 st = _row (state , r ["url" ])
912912 cf = st .get ("consecutive_failures" , 0 )
@@ -916,6 +916,8 @@ def cmd_report(args):
916916 dead .append ((r , st ))
917917 elif r ["class" ] == "SUSPECT" and cf >= 2 :
918918 suspect .append ((r , st ))
919+ elif r ["class" ] == "OK" and r .get ("redirect_to" ):
920+ moved .append ((r , st ))
919921
920922 def table (rows ):
921923 lines = ["| | Section | Entry | URL | Status | Redirects to | First failed |" ,
@@ -939,10 +941,33 @@ def table(rows):
939941 "Please open a pull request to fix an entry -- don't reply here with fixes, "
940942 "this issue body is fully rewritten on every run._" )
941943 out .append ("" )
944+ if args .fix_pr_url :
945+ out .append (
946+ f"\U0001F916 An automated fix is open: { escape_md_cell (args .fix_pr_url )} -- "
947+ "it only covers confirmed-dead removals and moved-URL updates below; "
948+ "review and merge it, or edit it further before merging. Suspect links "
949+ "always need a human look, and aren't included."
950+ )
951+ out .append ("" )
942952 out .append (f"## Dead links ({ len (dead )} )" )
943953 out .append ("" )
944954 out .append (table (dead ) if dead else "_None._" )
945955 out .append ("" )
956+ out .append (f"## Moved links ({ len (moved )} )" )
957+ out .append ("" )
958+ if moved :
959+ out .append ("| Section | Entry | Old URL | Redirects to |" )
960+ out .append ("|---|---|---|---|" )
961+ for r , st in moved :
962+ out .append ("| {section} | {title} | {url} | {redirect} |" .format (
963+ section = escape_md_cell (r .get ("section" )),
964+ title = escape_md_cell (r .get ("title" )),
965+ url = escape_md_cell (r .get ("url" )),
966+ redirect = escape_md_cell (r .get ("redirect_to" )),
967+ ))
968+ else :
969+ out .append ("_None._" )
970+ out .append ("" )
946971 out .append (f"## Suspect links ({ len (suspect )} )" )
947972 out .append ("" )
948973 out .append (table (suspect ) if suspect else "_None._" )
@@ -968,6 +993,135 @@ def table(rows):
968993 return 0
969994
970995
996+ # --------------------------------------------------------------------------
997+ # cmd: prune (safe auto-fixes: remove confirmed-dead entries, update moved URLs)
998+ # --------------------------------------------------------------------------
999+
1000+ def _series_children (items , idx ):
1001+ """Contiguous deeper-indented items right after items[idx] (a series), stopping
1002+ at the first item back at or above its depth, a header, or EOF. Mirrors the
1003+ lookahead parse_readme() uses for E002, so "does this series still have a
1004+ child" can't drift between the two.
1005+ """
1006+ depth = items [idx ][1 ][1 ]
1007+ children = []
1008+ j = idx + 1
1009+ while j < len (items ):
1010+ _ , kind = items [j ]
1011+ if kind [0 ] == "blank" :
1012+ j += 1
1013+ continue
1014+ if kind [0 ] == "header" :
1015+ break
1016+ if kind [0 ] in ("entry" , "series" ):
1017+ if kind [1 ] <= depth :
1018+ break
1019+ children .append (j )
1020+ j += 1
1021+ continue
1022+ break
1023+ return children
1024+
1025+
1026+ def cmd_prune (args ):
1027+ """Apply only the safe, unambiguous fixes a human would otherwise type by hand:
1028+ delete entries confirmed dead for 2+ consecutive weeks (no known redirect), and
1029+ rewrite an entry's URL in place when it now redirects to a different working
1030+ page. Anything ambiguous (SUSPECT, a dead/moved URL that's a secondary link in
1031+ an entry's prose rather than its primary URL, a URL matching more than one
1032+ entry) is left untouched for a human to judge -- this never runs unreviewed,
1033+ it's meant to land as a pull request.
1034+ """
1035+ with open (args .results , encoding = "utf-8" ) as fh :
1036+ results_doc = json .load (fh )
1037+ state = load_state_file (args .state )
1038+
1039+ with open (args .readme , encoding = "utf-8" ) as fh :
1040+ text = fh .read ()
1041+ lines = text .split ("\n " )
1042+
1043+ doc = parse_readme (text )
1044+ entries_by_url = {}
1045+ for e in doc .entries :
1046+ entries_by_url .setdefault (normalize_url (e .url ), []).append (e )
1047+
1048+ removals , updates = [], []
1049+ for r in results_doc ["results" ]:
1050+ key = "|" .join (normalize_url (r ["url" ]))
1051+ st = state .get (key )
1052+ cf = st .get ("consecutive_failures" , 0 ) if isinstance (st , dict ) else 0
1053+ matches = entries_by_url .get (normalize_url (r ["url" ]), [])
1054+ if len (matches ) != 1 :
1055+ continue # not a primary entry URL, or a duplicate -- leave for a human
1056+ entry = matches [0 ]
1057+ if r ["class" ] == "HARD_DEAD" and cf >= 2 and not r .get ("redirect_to" ):
1058+ removals .append ({"line" : entry .line , "url" : r ["url" ], "title" : entry .title , "section" : entry .section })
1059+ elif r ["class" ] == "OK" and r .get ("redirect_to" ) and r ["redirect_to" ] != r ["url" ]:
1060+ updates .append ({"line" : entry .line , "old_url" : r ["url" ], "new_url" : r ["redirect_to" ], "title" : entry .title , "section" : entry .section })
1061+
1062+ if not removals and not updates :
1063+ result = {"changed" : False , "removals" : [], "updates" : []}
1064+ print (json .dumps (result , indent = 2 ))
1065+ return 0
1066+
1067+ for u in updates :
1068+ idx = u ["line" ] - 1
1069+ lines [idx ] = lines [idx ].replace (f"]({ u ['old_url' ]} )" , f"]({ u ['new_url' ]} )" , 1 )
1070+
1071+ delete_lines = {r ["line" ] for r in removals }
1072+ changed = True
1073+ while changed :
1074+ changed = False
1075+ items = [(i + 1 , classify_line (ln )) for i , ln in enumerate (lines )]
1076+ for idx , (lineno , kind ) in enumerate (items ):
1077+ if lineno in delete_lines or kind [0 ] != "series" :
1078+ continue
1079+ children = _series_children (items , idx )
1080+ if children and all (items [c ][0 ] in delete_lines for c in children ):
1081+ delete_lines .add (lineno )
1082+ changed = True
1083+
1084+ new_lines = [ln for i , ln in enumerate (lines ) if (i + 1 ) not in delete_lines ]
1085+
1086+ with open (args .readme , "w" , encoding = "utf-8" ) as fh :
1087+ fh .write ("\n " .join (new_lines ))
1088+
1089+ if args .pr_body_out :
1090+ with open (args .pr_body_out , "w" , encoding = "utf-8" ) as fh :
1091+ fh .write (render_prune_pr_body (removals , updates ))
1092+
1093+ result = {
1094+ "changed" : True ,
1095+ "removals" : removals ,
1096+ "updates" : updates ,
1097+ "removed_lines" : sorted (delete_lines ),
1098+ }
1099+ print (json .dumps (result , indent = 2 ))
1100+ return 0
1101+
1102+
1103+ def render_prune_pr_body (removals , updates ):
1104+ out = ["Automated fix from the weekly link-rot sweep -- only confirmed-dead "
1105+ "removals and moved-URL updates, nothing else is touched." , "" ]
1106+ if removals :
1107+ out .append (f"### Removed ({ len (removals )} ) -- confirmed dead for 2+ consecutive weekly checks" )
1108+ out .append ("" )
1109+ for r in removals :
1110+ out .append (f"- **{ escape_md_cell (r ['title' ])} ** ({ escape_md_cell (r ['section' ])} ) -- { escape_md_cell (r ['url' ])} " )
1111+ out .append ("" )
1112+ if updates :
1113+ out .append (f"### Updated ({ len (updates )} ) -- now redirects to a different working URL" )
1114+ out .append ("" )
1115+ for u in updates :
1116+ out .append (f"- **{ escape_md_cell (u ['title' ])} ** ({ escape_md_cell (u ['section' ])} ) -- { escape_md_cell (u ['old_url' ])} -> { escape_md_cell (u ['new_url' ])} " )
1117+ out .append ("" )
1118+ out .append (
1119+ "See the open \U0001F517 Link rot report issue for suspect links that need a "
1120+ "human look -- those aren't included here."
1121+ )
1122+ return "\n " .join (out )
1123+
1124+
9711125# --------------------------------------------------------------------------
9721126# CLI
9731127# --------------------------------------------------------------------------
@@ -1001,8 +1155,16 @@ def build_parser():
10011155 report = sub .add_parser ("report" , help = "render the link-rot markdown issue body" )
10021156 report .add_argument ("--results" , required = True )
10031157 report .add_argument ("--state" , required = True )
1158+ report .add_argument ("--fix-pr-url" , default = None , help = "if set, link to this PR as the pending automated fix" )
10041159 report .set_defaults (func = cmd_report )
10051160
1161+ prune = sub .add_parser ("prune" , help = "apply safe auto-fixes: remove confirmed-dead entries, update moved URLs" )
1162+ prune .add_argument ("--results" , required = True )
1163+ prune .add_argument ("--state" , required = True )
1164+ prune .add_argument ("--readme" , default = DEFAULT_README )
1165+ prune .add_argument ("--pr-body-out" , default = None , help = "if set and something changed, write a PR body summary here" )
1166+ prune .set_defaults (func = cmd_prune )
1167+
10061168 return p
10071169
10081170
0 commit comments