← Build logs
SEOMay 28, 2026

How I Solved Naver Search Advisor Auto-Submission Using Playwright Cookie Re-use (2026)

One-Line Summary

Naver doesn't have "app passwords" like Google, so bots can't automatically log in with just an ID/PW for accounts with 2-factor authentication enabled. I solved this by logging in once manually on my local PC using headless mode to extract cookies, and then the server bot reuses those cookies. This allows automation without ever storing passwords and while keeping 2FA enabled. The result is a system that automatically submits 5 articles daily at 09:45 KST.

Why I Did This

My `sitemap.xml` was fine, and I was submitting to IndexNow, but Naver indexing was almost non-existent. Separate from GSC, Naver's Yetibot seems to have a pattern where it only scrapes `sitemap.xml` for new domains and doesn't fetch individual articles. The official recommended solution is to paste URLs one by one into "Search Advisor → Request → Webpage Collection." While there's a daily limit of 50, manually pasting 5-10 articles every day didn't align with my self-sufficient, one-person operating system.

Attempt 1: Automatic ID/PW Login (Failed)

The simplest approach: put `NAVER_SA_USERNAME` / `NAVER_SA_PASSWORD` in `.env` and have Playwright fill them in. I even simulated human typing with `page.keyboard.type(..., delay=80)`.

id_field = await page.wait_for_selector("#id", timeout=8000)
await id_field.click()
await page.keyboard.type(username, delay=80)
pw_field = await page.wait_for_selector("#pw", timeout=4000)
await pw_field.click()
await page.keyboard.type(password, delay=80)
await page.click("button[type='submit']")

The Result — If 2FA is enabled, it gets stuck on the login page:

  • Redirects to "OTP Confirmation" → bot can't enter OTP
  • New device registration screen → requires approval via your phone's push notification
  • Then CAPTCHA appears, and it's over

For services like Google or GitHub, you can issue "app passwords" and use those 16-digit codes for automated logins, but Naver doesn't have such an API/UI. Even going into Security Settings → Two-Factor Authentication, it only shows "Select Authentication Device" without an option to issue bot tokens. I found this out by trying it myself.

Alternative Analysis

OptionProsCons
Disable 2FAEasiest to implementMain account security ↓ — Risk to email/Pay/banking too
Cookie Reuse (storage_state)Automation with 2FA enabled / No password stored anywhereRequires 1-time re-issuance when cookies expire (1-3 months)
Abandon Automation0 lines of codeManually paste 5 entries daily — Contradiction to self-operation

I decided on the cookie reuse option.

Attempt 2: storage_state Cookie Reuse (Success)

Playwright can save cookies + localStorage to a JSON file using `browser_context.storage_state(path=...)` and then restore them with `new_context(storage_state=...)`. This is the key.

Step 1 — Headful Login + Capture on Local PC

async with async_playwright() as p:
    browser = await p.chromium.launch(headless=False)  # Needs to be seen by a human
    context = await browser.new_context(locale="ko-KR")
    page = await context.new_page()
    await page.goto("https://nid.naver.com/nidlogin.login")
    # Manually log in + approve 2FA push here
    input("Press Enter after login is complete > ")
    await context.storage_state(path="naver_session.json")
    await browser.close()

Note: You must manually navigate to `https://searchadvisor.naver.com/console/site/request/crawl` once and see the normal page before pressing Enter. This ensures cookies for the `searchadvisor` domain are also captured. (Naver SSO is a broad `.naver.com` domain where NID_AUT/NID_SES are shared, but it's safer to visit once.)

Step 2 — Upload Cookie File to Server

scp naver_session.json \
  user@server:/path/to/secrets/naver_session.json
# Add to .env:
# NAVER_SA_STORAGE_STATE_PATH=/path/to/secrets/naver_session.json

Placing the cookie file in a directory automatically handled by the `.gitignore` pattern `*secret*` (e.g., `secrets/`) ensures a 0% risk of accidental commits.

Step 3 — Server Bot Loads Only Cookies

async with async_playwright() as p:
    browser = await p.chromium.launch(headless=True)
    context = await browser.new_context(
        storage_state="/path/to/secrets/naver_session.json",
        locale="ko-KR",
    )
    page = await context.new_page()
    # Can now access SA already logged in
    await page.goto("https://searchadvisor.naver.com/console/site/request/crawl?site=...")

Trap 3: Vuetify SPA Selectors Not Found

The first automatic submission attempt failed for all 5 entries with "Could not find URL input field." Common selectors like `input[name="url"]`, `#url`, `input[placeholder*="URL"]` all failed to match.

The cause — Naver SA is a Vuetify (Vue 2) SPA. Due to dynamic hydration, input IDs are generated differently each time (e.g., `input-209`, `input-214`...) and there's no `name` attribute. Looking at the HTML:

{
  type: "text", name: "", id: "input-209",
  placeholder: "", cls: "", ariaLabel: null
}

Another issue — the crawl page redirects to an "Error" page if the `?site=` parameter is missing. This means you need to provide context specific to each site:

from urllib.parse import quote
url = f"https://searchadvisor.naver.com/console/site/request/crawl?site={quote('https://aicoreutility.com', safe='')}"
await page.goto(url, wait_until="networkidle")
await asyncio.sleep(3)  # Wait for Vue hydration

Finding Selectors with a Diagnostic Script

For SPAs like this, the following pattern works well — dump all input/buttons on the page and have a human match them:

inputs = await page.query_selector_all("input")
for i, el in enumerate(inputs):
    attrs = await el.evaluate(
        "e => ({type:e.type, name:e.name, id:e.id, "
        "placeholder:e.placeholder, cls:e.className})"
    )
    print(f"[{i}]", attrs)

btns = await page.query_selector_all("button")
for el in btns:
    txt = (await el.text_content() or "").strip()
    cls = await el.get_attribute("class") or ""
    print(f"text={txt!r} class={cls}")

From this output, I found the page's only text input and the primary button with the text "확인" (Confirm):

# URL Input — The only input[type=text] on the page
url_input = await page.wait_for_selector('input[type="text"]', timeout=8000)
await url_input.fill(url)

# Submit — The font-weight-bold button with text "확인"
submit_btn = await page.wait_for_selector(
    'button.font-weight-bold:has-text("확인"), button:has-text("확인")',
    timeout=5000,
)
await submit_btn.click()

Rerunning with these selectors → 5/5 submissions successful.

Productionizing — Automatic Dedup + Limits + Expiration Warnings

It wasn't over just because it worked once. For operational stability, three more things were needed:

1. 30-Day Dedup — Prevent Resubmitting the Same URL

CREATE TABLE seo_naver_submissions (
  id            BIGSERIAL PRIMARY KEY,
  url           TEXT NOT NULL,
  submitted_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  success       BOOLEAN NOT NULL,
  error_message TEXT,
  attempt_count INT NOT NULL DEFAULT 1
);
-- Skip URLs that were successfully submitted within the last 30 days
SELECT DISTINCT url FROM seo_naver_submissions
WHERE success=TRUE
  AND submitted_at > NOW() - INTERVAL '30 days';

2. Daily Limit of 5 — Conservative Safety Measure Below Naver's Actual Limit (50)

The official limit is 50, but submitting too many at once risks bot detection. This is controlled by the `NAVER_SA_DAILY_LIMIT=5` environment variable. Since I publish 1-2 articles per day, 5 is enough to keep up without missing any.

3. Cookie Expiration 60-Day Warning — Yellow Banner on Admin Dashboard

async def cookie_status() -> dict:
    path = os.environ.get("NAVER_SA_STORAGE_STATE_PATH", "")
    if not os.path.isfile(path):
        return {"exists": False, "reason": "Cookie file not found"}
    stat = os.stat(path)
    age_days = (time.time() - stat.st_mtime) / 86400
    return {
        "exists": True, "age_days": round(age_days, 1),
        "warn_renew": age_days > 60,  # Recommend re-issuance if older than 60 days
    }

This value appears as a card on the admin SEO dashboard, prompting me to rerun the headful capture once before expiration. It's a 10-minute task every 1-3 months.

4. APScheduler for Daily Automation

scheduler.add_job(
    run_naver_sa_submit_guarded,
    CronTrigger(hour=0, minute=45, timezone="UTC"),  # 09:45 KST
    id="naver_sa_submit_daily",
    replace_existing=True,
)

Choosing this time is just before the daily GSC sitemap sync (10:00 KST). If there are newly indexed articles, notifying Google first and then Naver in the same flow feels natural.

Measured Results

MetricBeforeAfter
Daily Manual Work5 entries × ~30s = 2.5 mins0s
Monthly Cumulative Submissions~80 (many days forgotten)150 (automatic)
My Account Security2FA enabled2FA still enabled
Failure ModeForgettingCookie expiration (once every 1-3 months)

Lessons Learned

  1. Don't assume "app passwords" exist. While common with Google/GitHub/Atlassian, Korean services like Naver/Nate/Kakao mostly lack this concept. Always check the SSO provider's actual authentication surface before designing automation.
  2. Cookie reuse is a good compromise for "no password storage" + "2FA compatibility." If you can accept the expiration cycle, it's the safest form of automation.
  3. Vuetify/Material SPAs have different selectors than SSR HTML. `name`/`id` are dynamic → use text matching or the page's sole element. If selectors visible in dev tools don't work in automation, it's 100% a hydration issue.
  4. "Limit per X" directly correlates with operational freedom. Even with an official limit of 50, submitting only 5 avoids bot detection, and for a pace of 1-2 articles/day, 5 is sufficient to keep up. Don't use the limit fully.

Related Files

  • riel_backend/services/naver_sa_submitter.py — Playwright submitter + DB logging
  • riel_backend/scripts/naver_login_capture.py — Local headful capture helper
  • riel_backend/api/seo_health.py — Admin endpoints (cookie status / manual trigger / history)
  • riel_backend/main.py — APScheduler daily at 09:45 KST

In the next post, I'll cover the results of a URL Inspection diagnosis on GSC done at the same time — tracking down the causes for 22 "Discovered - not indexed" items.