Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

// request.cf · coarse context

A page that knows where it met you.

Only coarse request metadata is shown. This demo does not display or persist visitor IP addresses.

Country
US
Cloudflare location
CMH
Connection
HTTP/2
Language
Not provided

Ray ID: a2295831ca242da2

Jump to content

Module:DYK scrutiny

From Wikipedia, the free encyclopedia

local p = {}

-- Each Nomination object corresponds to a single hook in a set
local Nomination = {}
Nomination.__index = Nomination

function Nomination.new()
    local self = setmetatable({}, Nomination)
    self.nomination = nil
    self.articles = {}
    self.editors = {}
    self.roles = {}
    return self
end

function Nomination:addArticle(page)
    table.insert(self.articles, page)
end

function Nomination:setNomination(page)
    self.nomination = page
end

function Nomination:addEditor(username)
    -- Check if username is in editors list
    for _, editor in ipairs(self.editors) do
        if editor == username then
            return -- Don't add a user multiple times
        end
    end
    table.insert(self.editors, username)
end

function Nomination:setRole(username, role)
    self.roles[username] = role
end

function Nomination:getRole(username)
    return self.roles[username]
end

function Nomination:addRole(username, role)
    -- Add the editor if they're not in the list
    self:addEditor(username)
    -- Set editor's role (creator, nominator, promoter, or blank)
    self:setRole(username, role)
end

-- Helper function to extract base article name and nomination number
-- Strips "(2nd nomination)", "(3rd nomination)", etc. from article names
function p.parseArticleName(article)
    local base, nomNum = article:match("^(.+)%s+%((%d+).. nomination%)$")
    if base then
        return base, tonumber(nomNum)
    end
    return article, 0 -- 0 is the original nomination (no suffix)
end

-- Compare article names with case-insensitive first letter
-- Wikipedia treats the first letter as case-insensitive: Apple == apple
function p.articleNamesMatch(name1, name2)
    if not name1 or not name2 then
        return false
    end
    
    -- Use mw.title.new to normalize (uppercases first letter automatically)
    local title1 = mw.title.new(name1)
    local title2 = mw.title.new(name2)
    
    if not title1 or not title2 then
        return false
    end
    
    -- Compare the prefixedText (normalized form)
    return title1.prefixedText == title2.prefixedText
end

-- Parser for bulleted hooks
function p.parseHooks(pageContent)
    local nominations = {}
    local foundAnyList = false

    -- Find the hooks section between <!--Hooks--> and <!--HooksEnd-->
    local hooksStart = pageContent:find("<!%-%-Hooks%-%->")
    local hooksEnd = pageContent:find("<!%-%-HooksEnd%-%->")
    
    if not hooksStart or not hooksEnd then
        return nil, "No list of hooks was found on the page."
    end
    
    local hooksSection = pageContent:sub(hooksStart, hooksEnd)

    -- Split content into lines
    for line in hooksSection:gmatch("[^\r\n]+") do
        -- Check if line starts with asterisk (bullet point)
        if line:match("^%*") then
            foundAnyList = true
            local nom = Nomination.new()

            -- Find all bold wiki links in the line: '''[[...]]'''
            -- Ignore non-bold links as they are not part of DYK scrutiny
            local pos = 1
            while pos <= #line do
                -- Look for opening '''
                local boldStart = line:find("'''", pos, true)
                if not boldStart then
                    break
                end

                -- Look for [[ after the '''
                local linkStart = line:find("(%[%[)", boldStart + 3)
                if not linkStart then
                    pos = boldStart + 3
                else
                    -- Find the closing ]] for this link
                    local linkEnd = line:find("(%]%])", linkStart + 2)
                    if not linkEnd then
                        pos = linkStart + 2
                    else
                        -- Check if there's a closing ''' after the ]]
                        local boldEnd = line:find("'''", linkEnd + 2, true)
                        if boldEnd and boldEnd == linkEnd + 2 then
                            -- Extract the link content between [[ and ]]
                            local linkContent = line:sub(linkStart + 2, linkEnd - 1)

                            -- Split on pipe to get article name (before pipe or whole thing)
                            local articleName = linkContent:match("^([^|]+)")
                            if articleName then
                                nom:addArticle(articleName)
                            end

                            pos = boldEnd + 3
                        else
                            pos = linkEnd + 2
                        end
                    end
                end
            end

            -- Only add nomination if it has at least one article
            if #nom.articles > 0 then
                table.insert(nominations, nom)
            end
        end
    end

    if not foundAnyList then
        return nil, "No list of hooks was found on the page."
    end
    
    if #nominations == 0 then
        return nil, "No links found in hooks on the page."
    end

    return nominations
end

-- Parse credits section and build a mapping of articles to their credit info
function p.parseCreditsAndRoles(pageContent)
    local creditsByArticle = {}
    
    -- Find the credits div
    local creditsStart = pageContent:find('<div id="credits">')
    if not creditsStart then
        creditsStart = pageContent:find('<div%s+id%s*=%s*"credits"%s*>')
    end
    
    if not creditsStart then
        return creditsByArticle -- Return empty table if no credits section
    end
    
    local creditsEnd = pageContent:find("</div>", creditsStart)
    if not creditsEnd then
        creditsEnd = #pageContent
    end
    
    local creditsSection = pageContent:sub(creditsStart, creditsEnd)
    
    -- Process {{DYKmake}} templates
    for dykMake in creditsSection:gmatch("{{DYKmake[^}]*}}") do
        -- Extract article name (parameter 1)
        local articleName = dykMake:match("{{DYKmake|([^|]+)|")
        if articleName then
            articleName = articleName:match("^%s*(.-)%s*$")
        end
        
        -- Extract subpage
        local subpage = dykMake:match("|%s*subpage%s*=%s*([^|}]+)")
        if subpage then
            subpage = subpage:match("^%s*(.-)%s*$")
        elseif not subpage and articleName then
            -- If no subpage parameter, use article name
            subpage = articleName
        end
        
        -- Extract creator (parameter 2)
        local creator = dykMake:match("{{DYKmake|[^|]+|([^|]+)")
        if creator then
            creator = creator:match("^%s*(.-)%s*$")
        end
        
        -- Skip placeholder credits with "Editor"
        if creator == "Editor" then
            creator = nil
        end
        
        -- Initialize article entry if needed
        if articleName and not creditsByArticle[articleName] then
            creditsByArticle[articleName] = {
                subpage = nil,
                creators = {},
                nominators = {},
                used = false
            }
        end
        
        -- Add subpage (first one wins)
        if articleName and subpage and not creditsByArticle[articleName].subpage then
            creditsByArticle[articleName].subpage = subpage
        end
        
        -- Add creator (avoid duplicates)
        if articleName and creator then
            local isDuplicate = false
            for _, existingCreator in ipairs(creditsByArticle[articleName].creators) do
                if existingCreator == creator then
                    isDuplicate = true
                    break
                end
            end
            if not isDuplicate then
                table.insert(creditsByArticle[articleName].creators, creator)
            end
        end
    end
    
    -- Process {{DYKnom}} templates
    for dykNom in creditsSection:gmatch("{{DYKnom[^}]*}}") do
        -- Extract article name (parameter 1)
        local articleName = dykNom:match("{{DYKnom|([^|]+)|")
        if articleName then
            articleName = articleName:match("^%s*(.-)%s*$")
        end
        
        -- Extract nominator (parameter 2)
        local nominator = dykNom:match("{{DYKnom|[^|]+|([^|}]+)")
        if nominator then
            nominator = nominator:match("^%s*(.-)%s*$")
        end
        
        -- Skip placeholder credits with "Nominator"
        if nominator == "Nominator" then
            nominator = nil
        end
        
        -- Initialize article entry if needed
        if articleName and not creditsByArticle[articleName] then
            creditsByArticle[articleName] = {
                subpage = articleName, -- Use article name as subpage default
                creators = {},
                nominators = {},
                used = false
            }
        end
        
        -- Add nominator (avoid duplicates)
        if articleName and nominator then
            local isDuplicate = false
            for _, existingNom in ipairs(creditsByArticle[articleName].nominators) do
                if existingNom == nominator then
                    isDuplicate = true
                    break
                end
            end
            if not isDuplicate then
                table.insert(creditsByArticle[articleName].nominators, nominator)
            end
        end
    end
    
    return creditsByArticle
end

-- Match nominations to credits (smart matching + fallback)
function p.matchCreditsToNominations(nominations, creditsByArticle)
    local unmatchedNominations = {}
    local unmatchedCredits = {}
    
    -- First pass: Smart matching by article names
    for _, nom in ipairs(nominations) do
        local matched = false
        local firstSubpage = nil
        
        -- Check if any of this nomination's articles appear in credits
        for _, article in ipairs(nom.articles) do
            -- Try to find matching credit (case-insensitive first letter)
            local matchingCredit = nil
            for creditArticle, creditInfo in pairs(creditsByArticle) do
                if p.articleNamesMatch(article, creditArticle) and not creditInfo.used then
                    matchingCredit = creditArticle
                    break
                end
            end
            
            if matchingCredit then
                local creditInfo = creditsByArticle[matchingCredit]
                matched = true
                
                -- Use first subpage we find
                if not firstSubpage and creditInfo.subpage then
                    firstSubpage = creditInfo.subpage
                end
                
                -- Add all creators from this article's credits
                for _, creator in ipairs(creditInfo.creators) do
                    nom:addRole(creator, "creator")
                end
                
                -- Add all nominators from this article's credits
                for _, nominator in ipairs(creditInfo.nominators) do
                    nom:addRole(nominator, "nominator")
                end
                
                -- Mark this credit as used
                creditInfo.used = true
            end
        end
        
        if matched then
            -- Set the nomination page using first subpage found
            if firstSubpage then
                nom:setNomination("Template:Did you know nominations/" .. firstSubpage)
            end
        else
            -- No match found, add to unmatched list
            table.insert(unmatchedNominations, nom)
        end
    end
    
    -- Collect unused credits (in order they appear)
    -- We need to preserve order, so iterate through credits by article names in appearance order
    for article, creditInfo in pairs(creditsByArticle) do
        if not creditInfo.used then
            table.insert(unmatchedCredits, {article = article, info = creditInfo})
        end
    end
    
    -- Second pass: Fallback positional matching
    for i, nom in ipairs(unmatchedNominations) do
        if unmatchedCredits[i] then
            local creditInfo = unmatchedCredits[i].info
            
            -- Set nomination page
            if creditInfo.subpage then
                nom:setNomination("Template:Did you know nominations/" .. creditInfo.subpage)
            end
            
            -- Add creators
            for _, creator in ipairs(creditInfo.creators) do
                nom:addRole(creator, "creator")
            end
            
            -- Add nominators
            for _, nominator in ipairs(creditInfo.nominators) do
                nom:addRole(nominator, "nominator")
            end
        end
    end
end

-- Month name from number
function p.getMonthName(monthNum)
    local months = {
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December"
    }
    return months[monthNum]
end

-- Get previous month/year
function p.getPreviousMonth(year, month)
    if month == 1 then
        return year - 1, 12
    else
        return year, month - 1
    end
end

-- Find promoter from JSON data
function p.findPromoter(jsonData, subpage)
    if not jsonData then
        return nil
    end

    for promoter, nominations in pairs(jsonData) do
        if nominations[subpage] then
            return promoter
        end
    end

    return nil
end

-- Parse promoters from GalliumBot JSON files
function p.parsePromoters(nominations)
    -- Get current date
    local currentYear = tonumber(os.date("%Y"))
    local currentMonth = tonumber(os.date("%m"))

    -- Get previous two months
    local prevYear, prevMonth = p.getPreviousMonth(currentYear, currentMonth)
    local prev2Year, prev2Month = p.getPreviousMonth(prevYear, prevMonth)

    -- Build page names for JSON files
    local currentMonthName = p.getMonthName(currentMonth)
    local prevMonthName = p.getMonthName(prevMonth)
    local prev2MonthName = p.getMonthName(prev2Month)

    local currentJsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", currentMonthName, currentYear)
    local prevJsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", prevMonthName, prevYear)
    local prev2JsonPage = string.format("User:GalliumBot/proctor/open/%s %d.json", prev2MonthName, prev2Year)

    -- Try to load JSON data
    local currentData = nil
    local prevData = nil
    local prev2Data = nil

    pcall(
        function()
            currentData = mw.loadJsonData(currentJsonPage)
        end
    )

    pcall(
        function()
            prevData = mw.loadJsonData(prevJsonPage)
        end
    )

    pcall(
        function()
            prev2Data = mw.loadJsonData(prev2JsonPage)
        end
    )

    -- For each nomination, try to find promoter
    for _, nom in ipairs(nominations) do
        if nom.nomination then
            -- Extract subpage from nomination path
            local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")

            if subpage then
                -- Try current month first
                local promoter = p.findPromoter(currentData, subpage)

                -- Try previous month
                if not promoter then
                    promoter = p.findPromoter(prevData, subpage)
                end

                -- If still no, try two months back
                -- Some of these seem to be placed a ways back
                if not promoter then
                    promoter = p.findPromoter(prev2Data, subpage)
                end

                -- If found, assign promoter role
                if promoter then
                    nom:addRole(promoter, "promoter")
                end
            end
        end
    end
end

-- Extract usernames from nomination page signature links
function p.parseEditors(pageContent, nomination)
    if not pageContent then
        return
    end

    local pos = 1
    while pos <= #pageContent do
        -- Look for [[User: pattern
        local userStart = pageContent:find("%[%[User:", pos)
        if not userStart then
            break
        end

        -- Username starts right after "[[User:"
        local usernameStart = userStart + 7 -- Length of "[[User:"

        -- Find the end of the username (|, #, or ]])
        local pipePos = pageContent:find("|", usernameStart, true)
        local bracketPos = pageContent:find("]]", usernameStart, true)

        -- Find # that is NOT preceded by & (HTML entity vs. anchor link)
        local hashPos = nil
        local searchPos = usernameStart
        while searchPos <= #pageContent do
            local nextHash = pageContent:find("#", searchPos, true)
            if not nextHash then
                break
            end
            -- If preceded by &
            if nextHash > 1 and pageContent:sub(nextHash - 1, nextHash - 1) == "&" then
                -- Skip the HTML entity
                searchPos = nextHash + 1
            else
                -- This # is a section link to the user's page, 
                -- probably for the promoter, possibly a custom signature
                hashPos = nextHash
                break
            end
        end

        -- Find the first delimiter
        local endPos = bracketPos
        if pipePos and (not endPos or pipePos < endPos) then 
            endPos = pipePos 
        end
        if hashPos and (not endPos or hashPos < endPos) then 
            endPos = hashPos 
        end

        if endPos then
            local username = pageContent:sub(usernameStart, endPos - 1)
            username = username:match("^%s*(.-)%s*$")
            
            if username and username ~= "" then
                nomination:addEditor(username)
            end
            -- Step past the delimiter to ensure progress
            pos = endPos + 2
        else
            -- Ensure progress on bogus wikilinks
            pos = usernameStart + 1
        end
    end
end

-- Combine hooks and credits, then fetch editors and promoters
function p.parseAll(pageContent)
    local nominations, err = p.parseHooks(pageContent)

    if err then
        return nil, err
    end

    -- Parse credits into article-based mapping
    local creditsByArticle = p.parseCreditsAndRoles(pageContent)
    
    -- Match credits to nominations (smart + fallback)
    p.matchCreditsToNominations(nominations, creditsByArticle)

    -- Promoters from GalliumBot JSON
    p.parsePromoters(nominations)

    -- Fetch editors from nomination pages and
    -- handle numbered nominations
    for _, nom in ipairs(nominations) do
        if nom.nomination then
            local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")
            if subpage then
                -- Parse the subpage to get base name
                local baseName = p.parseArticleName(subpage)

                -- Try to get the nomination page (use base name for lookup)
                local nomPageName = "Template:Did you know nominations/" .. baseName
                local title = mw.title.new(nomPageName)

                if title and title.exists then
                    local nominationContent = title:getContent()
                    p.parseEditors(nominationContent, nom)
                end
            end
        end
    end

    return nominations
end

-- Format output function
function p.formatOutput(nominations, heading)
    -- Group nominations by article display string
    local articleGroups = {}
    local orderCounter = 0

    for _, nom in ipairs(nominations) do
        if nom.nomination then
            local subpage = nom.nomination:match("Template:Did you know nominations/(.+)$")
            if subpage then
                -- Get base name and nomination number from subpage
                local baseName, nomNum = p.parseArticleName(subpage)

                -- Build display string with all articles
                local displayArticles = ""
                if #nom.articles > 0 then
                    local articleLinks = {}
                    for _, article in ipairs(nom.articles) do
                        local baseArticle = p.parseArticleName(article)
                        table.insert(articleLinks, "[[" .. baseArticle .. "]]")
                    end
                    displayArticles = table.concat(articleLinks, ", ")
                else
                    -- Fallback to subpage base name if no articles
                    displayArticles = "[[" .. baseName .. "]]"
                end

                -- Initialize group if needed
                if not articleGroups[displayArticles] then
                    orderCounter = orderCounter + 1
                    articleGroups[displayArticles] = {
                        subpage = subpage,
                        nomNum = nomNum,
                        users = {},
                        order = orderCounter
                    }
                end

                -- Update to use the highest nomination number subpage
                if nomNum > articleGroups[displayArticles].nomNum then
                    articleGroups[displayArticles].subpage = subpage
                    articleGroups[displayArticles].nomNum = nomNum
                end

                -- Add all editors with their roles
                for _, editor in ipairs(nom.editors) do
                    if not articleGroups[displayArticles].users[editor] then
                        articleGroups[displayArticles].users[editor] = {role = nom:getRole(editor)}
                    end
                end
            end
        end
    end

    -- Sort by order in queue
    local sortedArticles = {}
    for displayArticles, data in pairs(articleGroups) do
        table.insert(sortedArticles, {article = displayArticles, data = data})
    end
    table.sort(
        sortedArticles,
        function(a, b)
            return a.data.order < b.data.order
        end
    )

    local output = {}

    -- Add specified heading if provided
    if heading and heading ~= "" then
        table.insert(output, "==" .. heading .. "==")
        table.insert(output, "")
    end

    -- Generate entry for each article
    for _, item in ipairs(sortedArticles) do
        local article = item.article
        local data = item.data

        -- Create heading with link (article already contains [[ ]] formatting)
        local headingLine =
            "===" .. article .. " ([[Template:Did you know nominations/" .. data.subpage .. "|nom]]) ==="
        table.insert(output, headingLine)

        -- Fill Template:Reply to with users and role comments
        -- These comments have no effect on pings and are not visible
        -- to the pinged editors.
        local userEntries = {}
        for user, userData in pairs(data.users) do
            table.insert(userEntries, {name = user, role = userData.role})
        end

        -- Sort by role priority first, then alphabetically by name
        local rolePriority = {promoter = 1, creator = 2, nominator = 3}
        table.sort(
            userEntries,
            function(a, b)
                local aPriority = (a.role and rolePriority[a.role]) or 4
                local bPriority = (b.role and rolePriority[b.role]) or 4
                if aPriority ~= bPriority then
                    return aPriority < bPriority
                else
                    return a.name < b.name
                end
            end
        )

        local userStrings = {}
        for _, entry in ipairs(userEntries) do
            local userString = entry.name
            -- Only add role comment for the defined roles:
            if entry.role == "promoter" or entry.role == "creator" or entry.role == "nominator" then
                userString = userString .. "<!--" .. entry.role .. "-->"
            end
            table.insert(userStrings, userString)
        end

        local replyLine = "{{reply to|" .. table.concat(userStrings, "|") .. "}} ~~~~"
        table.insert(output, replyLine)
        table.insert(output, "") -- Blank line between entries
    end

    return table.concat(output, "\n")
end

-- Main entry point for Scribunto
function p.generatePings(frame)
    local pageName = frame.args[1] or frame:getParent().args[1]
    local heading = frame.args.heading or frame:getParent().args.heading or ""

    if not pageName then
        return "Error: No page name provided"
    end

    -- Get page content
    local title = mw.title.new(pageName)
    if not title or not title.exists then
        return "Error: Page does not exist"
    end

    local pageContent = title:getContent()
    local nominations, err = p.parseAll(pageContent)

    if err then
        return err
    end

    -- Format output
    local formattedOutput = p.formatOutput(nominations, heading)

    -- Return in syntaxhighlight box with copy button
    return frame:extensionTag("syntaxhighlight", formattedOutput, {lang = "wikitext", copy = "1"})
end

return p