DEV Community

박준희
박준희

Posted on • Originally published at aicoreutility.com

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']")
Enter fullscreen mode Exit fullscreen mode

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

Option Pros Cons
Disable 2FA Easiest to implement Main account security ↓ — Risk to email/Pay/banking too
Cookie Reuse (storage_state) Automation with 2FA enabled / No password stored anywhere Requires 1-time re-issuance when cookies expire (1-3 months)
Abandon Automation 0 lines of code Manually 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=...")
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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

Metric Before After
Daily Manual Work 5 entries × ~30s = 2.5 mins 0s
Monthly Cumulative Submissions ~80 (many days forgotten) 150 (automatic)
My Account Security 2FA enabled 2FA still enabled
Failure Mode Forgetting Cookie 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.

Top comments (0)