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
| 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()
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
| 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
-
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.
-
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.
-
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.
-
"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.