DEV Community

Building a Local-Only Chrome Extension That Autofills Job Application Forms

BuildContext: Collecting Surrounding Labels

function buildContext ( el ) {
    const parts = [];
    const seen = new Set ();
    const push = ( v ) => {
        if ( v == null ) return ;
        const t = stripExample ( String ( v ). replace ( / \s +/g , " " ). trim ()). replace ( / \s +/g , " " ). trim ();
        if ( t && t . length < 80 && ! seen . has ( t )) {
            seen . add ( t );
            parts . push ( t );
        }
    };
    // From attributes
    [ " name " , " id " , " placeholder " , " aria-label " , " autocomplete " , " title " , " data-label " ]
        . forEach (( a ) => push ( el . getAttribute ( a )));
    // aria-labelledby / label[for]
    const lb = el . getAttribute ( " aria-labelledby " );
    if ( lb ) lb . split ( / \s +/ ). forEach (( id ) => {
        const e = document . getElementById ( id );
        if ( e ) push ( e . textContent );
    });
    if ( el . id ) document . querySelectorAll ( `label[for="${ CSS . escape ( el . id )} "]` )
        . forEach (( l ) => push ( l . textContent ));
    // Walk up ancestors to pick up preceding label candidates (up to 5 levels)
    let node = el ;
    for ( let depth = 0 ; depth < 5 && node && node . tagName !== " BODY " ; depth ++ ) {
        if ( node . tagName === " TD " || node . tagName === " TH " ) {
            const row = node . closest ( " tr " );
            if ( row ) {
                const h = row . querySelector ( " th " ) || row . querySelector ( " td " );
                if ( h && h !== node ) push ( h . textContent );
            }
        }
        if ( node . tagName === " DD " && node . previousElementSibling ?. tagName === " DT " ) {
            push ( node . previousElementSibling . textContent );
        }
        // Check that the previous sibling contains no form controls before picking it up
        let sib = node . previousElementSibling , hops = 0 ;
        while ( sib && hops < 3 ) {
            const isControl = sib . matches ?.( " input, select, textarea, button " );
            const hasControl = sib . querySelectorAll ?.( " input, select, textarea " ). length > 0 ;
            if ( ! isControl && ! hasControl ) {
                const t = sib . textContent ?. trim ();
                if ( t ) { push ( t ); break ; }
            }
            sib = sib . previousElementSibling ;
            hops ++ ;
        }
        node = node . parentElement ;
    }
    return normalizeContext ( parts . join ( " | " ));
}

The key point is a design that can pick up labels across a <table>'s <th>, a <dl>'s <dt>, and a <div>'s previous sibling element. Job application forms write their HTML differently from site to site, mixing table layouts, div layouts, and dl layouts. To make it work with all of these, I needed to walk up the DOM ancestors to a certain depth while extracting "the text of elements that don't hold form controls." I also added preprocessing called stripExample. It's there to remove placeholder-like notes such as "example (last name: Matsushitafirst name: Taro)". Without it, the "first name" (名) that appears inside the example text for the "last name" (姓) field causes it to be misclassified as "first name."

classifyField: Classifying the Context String

function classifyField ( ctx ) {
    const has = ( re ) => re . test ( ctx );
    // Fields that should be skipped (highest priority)
    if ( has ( /頭文字|イニシャル|一文字|1文字/ )) return null ;
    if ( has ( /その他.*詳細|詳細を入力|系統その他|区分その他/ )) return null ;
    // Email (distinguish "email address 2" from the primary/confirmation field)
    if ( has ( /メール|e- ? mail|mail/ )) {
        const e2 = ctx . replace ( / [ 22 ][\s ] * ( 度|回|目|通 ) /g , " " );
        if ( / ( メールアドレス|メール )[\s ] * [ 22 ] |サブ|予備|セカンド|secondary/ . test ( e2 )) return " email2 " ;
        return " email " ;
    }
    if ( has ( /郵便|〒|zip|postal/ )) return " postalCode " ;
    if ( has ( /携帯|けいたい|mobile|cell/ )) return " phoneMobile " ;
    if ( has ( /自宅|固定電話|home. ? phone/ )) return " phoneHome " ;
    if ( has ( /電話|tel (?! l ) |phone/ )) return " phone " ;
    // High school (judged before university)
    if ( has ( /高校|高等学校|出身高/ )) {
        if ( has ( /卒業|修了/ )) return " highSchoolGradYear " ;
        if ( has ( /入学/ )) return " highSchoolAdmYear " ;
        return " highSchool " ;
    }
    if ( has ( /卒業|修了|graduat/ )) return " graddate " ;
    if ( has ( /生年月日|誕生日|date. ? of. ? birth/ )) return " birthdate " ;
    // Name (strip the "名" in compound words so it isn't misread before judging)
    const stripped = ctx . replace ( /氏名|お名前|名前|フリガナ|ふりがな|カナ氏名|fullname|name|kana/g , " " );
    const COMPOUND = / ( 地名|署名|記名|件名|品名|名称|名義|会社名|学校名|大学名 ) / ;
    const isKana = has ( /フリガナ|ふりがな|カナ|カナ|kana|furigana/ );
    const isLast = /姓|せい|セイ|苗字|名字|last|family/ . test ( stripped );
    const isFirst = /めい|メイ|first|given/ . test ( stripped ) || ( /名/ . test ( stripped ) && ! COMPOUND . test ( ctx ));
    const hasFull = has ( /氏名|お名前|名前|fullname| \b name \b / );
    if ( isKana ) {
        if ( isLast ) return " lastNameKana " ;
        if ( isFirst ) return " firstNameKana " ;
        return " fullNameKana " ;
    }
    if ( isLast ) return " lastName " ;
    if ( isFirst ) return " firstName " ;
    if ( hasFull ) return " fullName " ;
    // Overseas-only fields (judged last. Skip)
    if ( has ( /海外在住|日本国外|overseas/ )) return null ;
    return null ;
}

This function is a pure function. It has no DOM references - it just takes a string and returns a classification key - so it can be tested directly in Node.js without jsdom. This is one of the important design decisions in the code.

Handling Birth Dates and Graduation Dates Split Across Three Fields

Date fields are especially troublesome on job application forms. There are many patterns where "year / month / day" are split into separate <select> elements, and to make it worse, the <option> value might be 2003, or '03, or 2003年, differing from site to site.

detectDateRole in filler.js infers "is this year, month, or day?" from the value range of the select's options.

function detectDateRole ( el ) {
    const nums = Array . from ( el . options )
        . map (( o ) => parseInt (( o . value || o . textContent ). replace ( / [^ 0-9 ] /g , "" ), 10 ))
        . filter (( x ) => ! isNaN ( x ));
    if ( ! nums . length ) return null ;
    const max = Math . max (... nums );
    if ( max >= 1900 ) return " year " ;
    if ( max <= 12 ) return " month " ;
    if ( max <= 31 ) return " day " ;
    return null ;
}

If the maximum value is 1900 or above it's "year," 12 or below it's "month," and 31 or below it's "day." Simple, but it handles nearly every pattern you see on job application forms.

The selection is handled by selectNumber. It absorbs notation variations like '5', '05', '5月', and '5日'.

function selectNumber ( el , num ) {
    const n = parseInt ( num , 10 );
    if ( isNaN ( n )) return false ;
    return selectOption ( el , [
        String ( n ),
        String ( n ). padStart ( 2 , " 0 " ),
        ` ${ n } 月`,
        ` ${ n } 日`,
        ` ${ n } 年`,
        `平成 ${ n } `
    ]);
}

Supporting React / Vue Forms

Simply writing el.value = "..." has the problem that React and Vue "don't detect the change." Because these frameworks manage value through a virtual DOM, rewriting the DOM directly doesn't update the state, and the field can be treated as empty on submission.

setNativeValue in filler.js works around this problem.

function setNativeValue ( el , value ) {
    const proto = el . tagName === " TEXTAREA " ? HTMLTextAreaElement . prototype :
                  el . tagName === " SELECT " ? HTMLSelectElement . prototype :
                  HTMLInputElement . prototype ;
    const desc = Object . getOwnPropertyDescriptor ( proto , " value " );
    if ( desc && desc . set ) desc . set . call ( el , value );
    else el . value = value ;
    el . dispatchEvent ( new Event ( " input " , { bubbles : true }));
    el . dispatchEvent ( new Event ( " change " , { bubbles : true }));
}

The key point is pulling out the native setter with Object.getOwnPropertyDescriptor before calling it. Because React installs its change detection by overriding the value setter on HTMLInputElement.prototype, pulling the setter from the prototype rather than the instance and calling it lets you slip past that detection. On top of that, firing the input and change events makes Vue's v-model keep up too.

Handling Split Boxes for Phone and Postal Codes

There are also many patterns where a phone number is split into three like "090 | 1717 | 0135," or a postal code is split into "171 | 0031." fillSplitNumber handles this. It splits by digit count using standard patterns and pours the values into each box.

function fillSplitNumber ( els , value , kind ) {
    const digits = String ( value || "" ). replace ( / \D /g , "" );
    if ( ! digits ) return 0 ;
    let pattern ;
    if ( kind === " postal " ) {
        pattern = digits . length === 7 ? [ 3 , 4 ] : null ;
    } else if ( kind === " phone " ) {
        if ( els . length === 3 ) {
            if ( digits . length === 11 ) pattern = [ 3 , 4 , 4 ]; // Mobile 090-XXXX-XXXX
            else if ( digits . length === 10 ) pattern = digits . startsWith ( " 0 " ) ? [ 2 , 4 , 4 ] : [ 3 , 3 , 4 ]; // Landline 03-XXXX-XXXX
        }
    }
    // Use maxlength when it's trustworthy
    if ( ! pattern ) {
        const lens = els . map (( e ) => {
            const m = parseInt ( e . getAttribute ( " maxlength " ), 10 );
            return m > 0 && m < 20 ? m : null ;
        });
        pattern = lens . every (( l ) => l ) ? lens : null ;
    }
    const parts = pattern ? sliceByLens ( digits , pattern ) : [ digits ];
    let n = 0 ;
    els . forEach (( el , i ) => {
        if ( parts [ i ] != null ) {
            setNativeValue ( el , parts [ i ]);
            el . dispatchEvent ( new Event ( " blur " , { bubbles : true }));
            n ++ ;
        }
    });
    return n ? 1 : 0 ;
}

Kana Conversion: Handling Full-Width, Hiragana, and Half-Width Kana All at Once

The furigana field's requirements vary by site: "enter in full-width katakana," "enter in hiragana," "enter in half-width kana." I store the profile in full-width katakana and convert it according to the field's context hints.

function adaptKana ( value , hint ) {
    if ( /半角|ハンカク|hankaku|半角カナ/ . test ( hint )) return fullToHalfKana ( value );
    if ( /ひらがな|ふりがな|hiragana/ . test ( hint )) return kataToHira ( value );
    return value ; // Default is full-width katakana
}

Since buildContext collects up label text like "in half-width kana" or "enter in hiragana," I use those hints here.

Deciding Button Display: Determining Whether It's an Entry Form

The content script is injected into <all_urls>. Showing an "autofill" button even on job search pages or company top pages would be intrusive, so it decides "is this a real entry form?" before displaying the button.

const MIN_CLASSIFIED = 2 ;
function looksLikeForm () {
    let n = 0 ;
    for ( const el of document . querySelectorAll ( TEXT_FIELDS )) {
        if ( classifyField ( buildContext ( el )) && ++ n >= MIN_CLASSIFIED ) return true ;
    }
    return false ;
}

The criterion is "there are two or more fields that can be classified into a profile item." A job-narrowing page just has a row of checkboxes, which don't classify into name, address, or email. On an entry form these are easily found two or more times, so a threshold of 2 is enough to discriminate. On SPAs (recruiting systems that switch between list ↔ form without a page transition), it watches DOM changes with a MutationObserver and dynamically shows/hides the button. le

Comments

No comments yet. Start the discussion.