import { sendEmail } from "../../channels/email/send"; import puppeteer, { type Browser, type Page } from "puppeteer"; import * as fs from "fs"; import * as path from "path"; const BASELINE_PATH = path.resolve(__dirname, "baseline.json"); const LAST_SENT = path.resolve(__dirname, ".last-sent"); const LOGS_DIR = path.resolve(__dirname, "logs"); type Registry = "US" | "WIPO" | "EU" | "KR" | "JP" | "CN"; const REGISTRY_NAMES: Record = { US: "USPTO (United States)", WIPO: "WIPO Madrid Monitor", EU: "EUIPO (European Union)", KR: "KIPRIS (South Korea)", JP: "J-PlatPat (Japan)", CN: "CNIPA (China)", }; interface TrackedMark { description: string; owner: string; source?: string; wipoIR?: string; lastStatus: string | null; lastChecked: string | null; } interface Deadline { serial: string; type: string; date: string; description: string; lateDateWithFee: string; } interface TrademarkResult { registry: Registry; serial: string; markText: string; status: string; classes: string; owner: string; } interface Baseline { trackedMarks: Record; deadlines: Deadline[]; searchTerms: string[]; targetClasses: string[]; watchEntities: Array<{ name: string; description: string; domains: string[] }>; knownLiveSerials: Record; lastRun: string | null; } interface Alert { priority: "high" | "medium" | "info"; type: "deadline" | "status-change" | "status-check" | "new-filing" | "search-error"; message: string; } function today(): string { return new Date().toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" }); } function daysUntil(dateStr: string): number { const now = new Date(today()); const target = new Date(dateStr); return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); } function loadBaseline(): Baseline { const raw = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); if (!raw.knownLiveSerials) raw.knownLiveSerials = {}; migrateBaseline(raw); return raw; } function migrateBaseline(baseline: Baseline): void { const kls = baseline.knownLiveSerials; const needsMigration = Object.keys(kls).some((k) => !k.includes(":")); if (!needsMigration) return; const migrated: Record = {}; for (const [key, serials] of Object.entries(kls)) { if (key.includes(":")) { migrated[key] = serials; } else { migrated[`US:${key}`] = serials; } } baseline.knownLiveSerials = migrated; } function saveBaseline(baseline: Baseline): void { fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2)); } async function fetchJSON(url: string, timeoutMs = 15000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { clearTimeout(timer); } } async function fetchText(url: string, timeoutMs = 15000): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } finally { clearTimeout(timer); } } async function setupPage(browser: Browser): Promise { const page = await browser.newPage(); await page.setUserAgent( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", ); await page.setViewport({ width: 1280, height: 800 }); await page.setExtraHTTPHeaders({ "Accept-Language": "en-US,en;q=0.9" }); return page; } async function debugScreenshot(page: Page, name: string): Promise { try { fs.mkdirSync(LOGS_DIR, { recursive: true }); await page.screenshot({ path: path.join(LOGS_DIR, `debug-${name}.png`), fullPage: true }); console.log(`[trademark-watch] screenshot saved: debug-${name}.png`); } catch {} } async function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } async function trySelectors(page: Page, selectors: string[]): Promise { for (const sel of selectors) { const el = await page.$(sel); if (el) return el; } return null; } async function checkUSPTOSerial(serial: string): Promise<{ status: string; maintenance: any } | null> { try { const url = `https://tmsearch.uspto.gov/tsdr-api-v1-0-0/tsdr-api?serialNumber=${serial}`; const data = await fetchJSON(url); const status = data?.metadata?.caseStatus || data?.metadata?.tm5Status?.tm5StatusDescription || "unknown"; return { status, maintenance: data?.maintenance || null }; } catch (err: any) { console.error(`[trademark-watch] TSDR check failed for ${serial}: ${err.message}`); return null; } } async function checkWIPOMadrid(irNumber: string): Promise<{ status: string; designations: Record } | null> { try { const url = `https://www3.wipo.int/madrid/monitor/en/showData.jsp?ID=${irNumber}`; const html = await fetchText(url); const statusMatches = [...html.matchAll(/"status":"([^"]+)"/g)]; const statuses = statusMatches.map((m) => m[1]); const designationBlock = html.match(/var\s+\w+\s*=\s*(\{[^;]+\})/); let designations: Record = {}; if (designationBlock) { try { const parsed = JSON.parse(designationBlock[1]); for (const [country] of Object.entries(parsed)) { designations[country] = "tracked"; } } catch {} } const liveStatuses = statuses.filter((s) => s === "inscribed" || s === "I" || s === "E" || s === "Z"); const overallStatus = liveStatuses.length > 0 ? `active (${liveStatuses.length} live designations)` : "unknown"; return { status: overallStatus, designations }; } catch (err: any) { console.error(`[trademark-watch] WIPO check failed for IR ${irNumber}: ${err.message}`); return null; } } // --- US: USPTO TESS --- async function searchUS(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { await page.goto("https://tmsearch.uspto.gov/search/search-information", { waitUntil: "networkidle2", timeout: 30000 }); await page.type("#searchbar", term); await delay(500); await page.click("button.btn.btn-primary.md-icon"); await delay(5000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const blocks = text.split(/Check to tag for /); for (let i = 1; i < blocks.length; i++) { const block = blocks[i]; const serialMatch = block.match(/^(\d+)/); if (!serialMatch) continue; const serial = serialMatch[1]; const statusLive = block.includes("LIVEREGISTERED") || block.includes("LIVEPENDING"); if (!statusLive) continue; const status = block.includes("LIVEREGISTERED") ? "LIVE/REGISTERED" : "LIVE/PENDING"; let markText = ""; const wordmarkMatch = block.match(/wordmark\n([^\n]+)/); if (wordmarkMatch) markText = wordmarkMatch[1].trim(); let classes = ""; const classMatch = block.match(/Class\n([0-9, ]+)/); if (classMatch) classes = classMatch[1].trim(); let owner = ""; const ownerMatch = block.match(/Owners\n([^\n]+)/); if (ownerMatch) owner = ownerMatch[1].trim(); results.push({ registry: "US", serial, markText, status, classes, owner }); } return results; } catch (err: any) { console.error(`[trademark-watch] US search failed for "${term}": ${err.message}`); await debugScreenshot(page, `us-${term}`); return []; } finally { await page.close(); } } // --- WIPO: Madrid Monitor --- async function searchWIPO(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { await page.goto("https://www3.wipo.int/madrid/monitor/en/", { waitUntil: "networkidle2", timeout: 45000 }); await delay(3000); const typed = await page.evaluate((searchTerm: string) => { const inputs = document.querySelectorAll('input[type="text"], input[type="search"], input:not([type])'); for (const inp of inputs) { const el = inp as HTMLInputElement; const rect = el.getBoundingClientRect(); if (rect.width > 50 && rect.height > 0 && rect.top > 0) { el.focus(); el.value = searchTerm; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { found: true, tag: el.tagName, id: el.id, name: el.name, w: rect.width }; } } return null; }, term); if (!typed) { await debugScreenshot(page, `wipo-no-input-${term}`); throw new Error("Could not find search input on Madrid Monitor"); } console.log(`[trademark-watch] WIPO: typed into input id="${typed.id}" name="${typed.name}" w=${typed.w}`); await delay(500); await page.keyboard.press("Escape"); await delay(300); await page.keyboard.press("Enter"); await delay(1000); const searchClicked = await page.evaluate(() => { const allEls = document.querySelectorAll("*"); for (const el of allEls) { const html = el as HTMLElement; const cls = (html.className || "").toString(); const onclick = html.getAttribute("onclick") || ""; if (cls.includes("fa-search") || cls.includes("icon-search") || cls.includes("glyphicon-search")) { const parent = html.closest("a, button, span, div") as HTMLElement; if (parent && parent !== html) { parent.click(); return "icon-parent"; } html.click(); return "icon-direct"; } if (onclick.includes("search") || onclick.includes("Search")) { html.click(); return "onclick"; } } return null; }); console.log(`[trademark-watch] WIPO: search click result: ${searchClicked}`); await delay(10000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); for (let i = 0; i < lines.length; i++) { const irnMatch = lines[i].match(/\b(\d{6,7})\b/); if (!irnMatch) continue; const serial = irnMatch[1]; const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 5)).join(" "); if (!context.toLowerCase().includes(term.toLowerCase())) continue; const statusMatch = context.match(/(Registered|Pending|Active|Protected|Expired|Cancelled)/i); const status = statusMatch ? statusMatch[1] : "unknown"; let owner = ""; const ownerMatch = context.match(/(?:Holder|Owner|Applicant)[:\s]+([^\n,]+)/i); if (ownerMatch) owner = ownerMatch[1].trim(); results.push({ registry: "WIPO", serial, markText: term, status, classes: "", owner }); } if (results.length === 0) { await debugScreenshot(page, `wipo-noresults-${term}`); } return results; } catch (err: any) { console.error(`[trademark-watch] WIPO search failed for "${term}": ${err.message}`); await debugScreenshot(page, `wipo-${term}`); return []; } finally { await page.close(); } } // --- EU: EUIPO eSearch Plus --- async function searchEU(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { await page.goto("https://euipo.europa.eu/eSearch/", { waitUntil: "networkidle2", timeout: 45000 }); await delay(3000); const cookieBtn = await trySelectors(page, [ 'button[id*="cookie"]', 'button[class*="cookie"]', 'a[id*="cookie"]', "#accept-cookies", ".cookie-accept", ]); if (cookieBtn) { await cookieBtn.click(); await delay(1000); } const input = await trySelectors(page, [ "#eSearchInput", 'input[name="searchCriteria"]', 'input[type="text"]', 'input[type="search"]', 'input[placeholder*="earch"]', "#searchText", ]); if (!input) { await debugScreenshot(page, `eu-no-input-${term}`); throw new Error("Could not find search input on EUIPO eSearch"); } await input.click({ clickCount: 3 }); await input.type(term); await delay(500); const submitBtn = await trySelectors(page, [ 'button[type="submit"]', "button.search-btn", "button.btn-search", "button.btn-primary", 'input[type="submit"]', ]); if (submitBtn) { await submitBtn.click(); } else { await page.keyboard.press("Enter"); } await delay(8000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); for (let i = 0; i < lines.length; i++) { const appMatch = lines[i].match(/\b(0\d{8,}|\d{7,9})\b/); if (!appMatch) continue; const serial = appMatch[1]; const context = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 5)).join(" "); if (!context.toLowerCase().includes(term.toLowerCase())) continue; const statusMatch = context.match(/(Registered|Filed|Published|Opposed|Refused|Withdrawn|Expired|Cancelled)/i); const status = statusMatch ? statusMatch[1] : "unknown"; const classMatch = context.match(/Class(?:es)?[:\s]+([0-9, ]+)/i); const classes = classMatch ? classMatch[1].trim() : ""; let owner = ""; const ownerMatch = context.match(/(?:Owner|Applicant|Proprietor)[:\s]+([^\n,]+)/i); if (ownerMatch) owner = ownerMatch[1].trim(); results.push({ registry: "EU", serial, markText: term, status, classes, owner }); } return results; } catch (err: any) { console.error(`[trademark-watch] EU search failed for "${term}": ${err.message}`); await debugScreenshot(page, `eu-${term}`); return []; } finally { await page.close(); } } // --- KR: KIPRIS --- async function searchKR(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { await page.goto("https://www.kipris.or.kr/khome/search/searchResult.do?tab=trademark", { waitUntil: "networkidle2", timeout: 45000, }); await delay(3000); // KIPRIS main search bar is textarea#queryText const inputInfo = await page.evaluate((searchTerm: string) => { // Strategy 1: Target known queryText element (textarea or input) const el = document.getElementById("queryText") as HTMLInputElement | HTMLTextAreaElement | null; if (el) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { el.focus(); el.value = searchTerm; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { id: el.id, name: el.name, w: rect.width, method: "known-id" }; } } // Strategy 2: Trademark-specific field sd010301_g04_text (mark name) const tmField = document.getElementById("sd010301_g04_text") as HTMLInputElement | null; if (tmField) { const rect = tmField.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { tmField.focus(); tmField.value = searchTerm; tmField.dispatchEvent(new Event("input", { bubbles: true })); tmField.dispatchEvent(new Event("change", { bubbles: true })); return { id: tmField.id, name: tmField.name, w: rect.width, method: "tm-field" }; } } // Strategy 3: First visible textarea or wide text input const inputs = document.querySelectorAll('textarea, input[type="text"], input[type="search"]'); for (const inp of inputs) { const iel = inp as HTMLInputElement; const rect = iel.getBoundingClientRect(); if (rect.width > 100 && rect.height > 20 && rect.top > 0 && rect.top < 300) { iel.focus(); iel.value = searchTerm; iel.dispatchEvent(new Event("input", { bubbles: true })); iel.dispatchEvent(new Event("change", { bubbles: true })); return { id: iel.id, name: iel.name, w: rect.width, method: "fallback" }; } } return null; }, term); if (!inputInfo) { await debugScreenshot(page, `kr-no-input-${term}`); throw new Error("Could not find search input on KIPRIS"); } console.log(`[trademark-watch] KR: typed into input id="${inputInfo.id}" name="${inputInfo.name}" method=${inputInfo.method}`); await delay(500); // Click the "검색" button closest to the queryText search bar (top ~137) const clickResult = await page.evaluate(() => { const btns = document.querySelectorAll('button, input[type="submit"], input[type="button"]'); let best: HTMLElement | null = null; let bestDist = Infinity; const targetTop = 137; // approximate position of the main search bar's submit for (const btn of btns) { const el = btn as HTMLElement; const text = (el.textContent || "").trim(); const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) continue; if (text === "검색" || text === "Search" || text === "검색하기") { const dist = Math.abs(rect.top - targetTop); if (dist < bestDist) { best = el; bestDist = dist; } } } if (best) { best.click(); const rect = best.getBoundingClientRect(); return `clicked: "${(best.textContent || "").trim()}" at top=${rect.top}`; } return null; }); console.log(`[trademark-watch] KR: submit result: ${clickResult}`); if (!clickResult) { await page.keyboard.press("Enter"); } await delay(8000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); for (let i = 0; i < lines.length; i++) { const appMatch = lines[i].match(/\b(40-?\d{4}-?\d{7}|\d{13})\b/); if (!appMatch) continue; const serial = appMatch[1]; const context = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 5)).join(" "); if (!context.toLowerCase().includes(term.toLowerCase())) continue; const statusMatch = context.match(/(Registered|Published|Pending|Refused|Cancelled|Expired|등록|출원|공고)/i); const status = statusMatch ? statusMatch[1] : "unknown"; let owner = ""; const ownerMatch = context.match(/(?:Applicant|Holder|Owner|출원인|등록권자)[:\s]+([^\n,]+)/i); if (ownerMatch) owner = ownerMatch[1].trim(); results.push({ registry: "KR", serial, markText: term, status, classes: "", owner }); } if (results.length === 0) { await debugScreenshot(page, `kr-noresults-${term}`); } return results; } catch (err: any) { console.error(`[trademark-watch] KR search failed for "${term}": ${err.message}`); await debugScreenshot(page, `kr-${term}`); return []; } finally { await page.close(); } } // --- JP: J-PlatPat --- async function searchJP(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { await page.goto("https://www.j-platpat.inpit.go.jp/t0100", { waitUntil: "networkidle2", timeout: 60000 }); await delay(5000); // Dismiss any agree/disclaimer dialog const agreeBtn = await trySelectors(page, [ 'button[class*="agree"]', 'button[id*="agree"]', 'a[class*="agree"]', ]); if (agreeBtn) { await agreeBtn.click(); await delay(2000); } // The trademark keyword field is textarea#t01_srchCondtn_mk_txtKeywd0 (label: キーワード) const inputInfo = await page.evaluate((searchTerm: string) => { // Strategy 1: Target the known keyword textarea by ID const knownIds = ["t01_srchCondtn_mk_txtKeywd0"]; for (const id of knownIds) { const el = document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | null; if (el) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { el.focus(); el.value = searchTerm; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { id: el.id, name: el.name, w: rect.width, top: rect.top, method: "known-id" }; } } } // Strategy 2: First visible wide textarea or text input (keyword fields are textareas) const inputs = document.querySelectorAll('textarea, input[type="text"], input:not([type])'); for (const inp of inputs) { const el = inp as HTMLInputElement; const rect = el.getBoundingClientRect(); if (rect.width > 200 && rect.height > 0 && rect.top > 0) { el.focus(); el.value = searchTerm; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { id: el.id, name: el.name, w: rect.width, top: rect.top, method: "first-wide" }; } } return null; }, term); if (!inputInfo) { await debugScreenshot(page, `jp-no-input-${term}`); throw new Error("Could not find search input on J-PlatPat"); } console.log(`[trademark-watch] JP: typed into input id="${inputInfo.id}" top=${inputInfo.top} method=${inputInfo.method}`); await delay(1000); // Click the submit button whose text is exactly "検索" (not a longer phrase containing 検索) const clickResult = await page.evaluate(() => { const btns = document.querySelectorAll('button, input[type="submit"], input[type="button"], a'); for (const btn of btns) { const el = btn as HTMLElement; const text = (el.textContent || "").trim(); const value = ((el as HTMLInputElement).value || "").trim(); const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) continue; // Match exactly "検索" or "Search" — not substrings in longer phrases if (text === "検索" || text === "Search" || value === "検索" || value === "Search") { el.click(); return `clicked: "${text || value}" at top=${rect.top}`; } } return null; }); console.log(`[trademark-watch] JP: submit result: ${clickResult}`); if (!clickResult) { await page.keyboard.press("Enter"); } await delay(10000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); // Helper: normalize full-width alphanumeric to half-width for comparison const normalize = (s: string) => s.replace(/[\uff01-\uff5e]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)).toLowerCase(); const termNorm = normalize(term); let lastRegLine = -10; for (let i = 0; i < lines.length; i++) { // Match Japanese registration numbers (登録1234567) or application numbers (商願2024-012345) const regMatch = lines[i].match(/登録(\d{6,8})/); const appMatch = lines[i].match(/商願[\d平成令和]*[-ー]?(\d{5,7})/); if (!regMatch && !appMatch) continue; // Skip application number lines that immediately follow a registration number (same entry) if (appMatch && !regMatch && i - lastRegLine <= 2) continue; if (regMatch) lastRegLine = i; const context = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 10)).join(" "); // Compare using normalized (half-width) text to match full-width marks if (!normalize(context).includes(termNorm)) continue; const statusMatch = context.match(/(存続[-ー]登録[-ー]継続|存続|登録|出願|公告|拒絶|消滅|失効)/); const status = statusMatch ? statusMatch[1] : "unknown"; let owner = ""; const ownerLines = lines.slice(Math.max(0, i + 3), Math.min(lines.length, i + 8)); for (const ol of ownerLines) { if (ol.match(/株式会社|有限会社|合同会社|[ァ-ヶー]+|[A-Za-z]{3,}/)) { owner = ol.trim(); break; } } const fullSerial = regMatch ? `登録${regMatch[1]}` : lines[i].match(/商願[^\)]+/)?.[0] || appMatch![1]; results.push({ registry: "JP", serial: fullSerial, markText: term, status, classes: "", owner }); } if (results.length === 0) { await debugScreenshot(page, `jp-noresults-${term}`); } return results; } catch (err: any) { console.error(`[trademark-watch] JP search failed for "${term}": ${err.message}`); await debugScreenshot(page, `jp-${term}`); return []; } finally { await page.close(); } } // --- CN: CNIPA --- // Note: CNIPA (wcjs.sbj.cnipa.gov.cn) blocks non-Chinese IPs via WAF. // This function attempts the search but will gracefully fail with a clear message. async function searchCN(browser: Browser, term: string): Promise { const page = await setupPage(browser); try { // Try HTTPS first, fall back to HTTP let loaded = false; for (const proto of ["https", "http"]) { try { await page.goto(`${proto}://wcjs.sbj.cnipa.gov.cn/txnT01014.do`, { waitUntil: "networkidle2", timeout: 20000 }); loaded = true; break; } catch (e: any) { console.log(`[trademark-watch] CN: ${proto} failed: ${e.message}`); } } if (!loaded) { throw new Error("CNIPA unreachable (site blocks non-Chinese IP addresses)"); } // Check if we got a WAF block page const bodyText = await page.evaluate(() => document.body?.innerText || ""); if (bodyText.includes("blocked") || bodyText.includes("Access Denied") || bodyText.includes("403") || bodyText.length < 200) { throw new Error("CNIPA blocked by WAF (site restricts access to Chinese IP addresses)"); } // If we made it here, try to search const engLink = await page.evaluate(() => { const links = document.querySelectorAll("a"); for (const link of links) { const text = link.textContent || ""; if (text.includes("English") || text.includes("EN")) { link.click(); return true; } } return false; }); if (engLink) await delay(2000); const allInputs = await page.$$('input[type="text"]'); let inputFound = false; for (const el of allInputs) { const visible = await el.evaluate((e) => { const style = window.getComputedStyle(e); return style.display !== "none" && style.visibility !== "hidden" && (e as HTMLElement).offsetParent !== null; }); if (visible) { await el.click({ clickCount: 3 }); await el.type(term); inputFound = true; break; } } if (!inputFound) { await debugScreenshot(page, `cn-no-input-${term}`); throw new Error("Could not find search input on CNIPA"); } await delay(500); const submitBtn = await trySelectors(page, [ 'button[type="submit"]', 'input[type="submit"]', 'input[type="button"][value*="查询"]', 'input[type="button"][value*="Search"]', "button.btn-search", ]); if (submitBtn) { await submitBtn.click(); } else { await page.keyboard.press("Enter"); } await delay(8000); const text = await page.evaluate(() => document.body?.innerText || ""); const results: TrademarkResult[] = []; const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); for (let i = 0; i < lines.length; i++) { const appMatch = lines[i].match(/\b(\d{7,9})\b/); if (!appMatch) continue; const serial = appMatch[1]; const context = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 5)).join(" "); if (!context.toLowerCase().includes(term.toLowerCase())) continue; const statusMatch = context.match(/(注册|已注册|初审公告|驳回|Registered|Published|Pending|Refused)/i); const status = statusMatch ? statusMatch[1] : "unknown"; let owner = ""; const ownerMatch = context.match(/(?:申请人|Applicant|Owner)[:\s]+([^\n,]+)/i); if (ownerMatch) owner = ownerMatch[1].trim(); results.push({ registry: "CN", serial, markText: term, status, classes: "", owner }); } return results; } catch (err: any) { console.error(`[trademark-watch] CN search failed for "${term}": ${err.message}`); return []; } finally { await page.close(); } } // --- Core logic --- type SearchFn = (browser: Browser, term: string) => Promise; const REGISTRY_SEARCHES: { registry: Registry; search: SearchFn }[] = [ { registry: "US", search: searchUS }, { registry: "WIPO", search: searchWIPO }, { registry: "EU", search: searchEU }, { registry: "KR", search: searchKR }, { registry: "JP", search: searchJP }, { registry: "CN", search: searchCN }, ]; function checkDeadlines(baseline: Baseline): Alert[] { const alerts: Alert[] = []; for (const deadline of baseline.deadlines) { const days = daysUntil(deadline.date); const lateDays = daysUntil(deadline.lateDateWithFee); if (days <= 0 && lateDays <= 0) { alerts.push({ priority: "high", type: "deadline", message: `EXPIRED: ${deadline.type} for serial ${deadline.serial} passed on ${deadline.date}. Late filing with fee also expired (${deadline.lateDateWithFee}). ${deadline.description}`, }); } else if (days <= 0) { alerts.push({ priority: "high", type: "deadline", message: `OVERDUE: ${deadline.type} for serial ${deadline.serial} was due ${deadline.date}. Late filing with fee available until ${deadline.lateDateWithFee} (${lateDays} days). ${deadline.description}`, }); } else if (days <= 30) { alerts.push({ priority: "high", type: "deadline", message: `URGENT: ${deadline.type} for serial ${deadline.serial} due in ${days} days (${deadline.date}). ${deadline.description}`, }); } else if (days <= 90) { alerts.push({ priority: "medium", type: "deadline", message: `Approaching: ${deadline.type} for serial ${deadline.serial} due in ${days} days (${deadline.date}). ${deadline.description}`, }); } } return alerts; } async function checkTrackedMarks(baseline: Baseline): Promise { const alerts: Alert[] = []; const todayStr = today(); for (const [serial, mark] of Object.entries(baseline.trackedMarks)) { if (mark.source === "madrid" && mark.wipoIR) { const result = await checkWIPOMadrid(mark.wipoIR); if (!result) { alerts.push({ priority: "info", type: "status-check", message: `Could not check WIPO IR ${mark.wipoIR} (${mark.description})` }); continue; } if (mark.lastStatus && mark.lastStatus !== result.status) { alerts.push({ priority: "high", type: "status-change", message: `Status changed for IR ${mark.wipoIR} (${mark.description}): "${mark.lastStatus}" -> "${result.status}"`, }); } mark.lastStatus = result.status; mark.lastChecked = todayStr; } else { const result = await checkUSPTOSerial(serial); if (!result) { alerts.push({ priority: "info", type: "status-check", message: `Could not check serial ${serial} (${mark.description})` }); continue; } if (mark.lastStatus && mark.lastStatus !== result.status) { alerts.push({ priority: "high", type: "status-change", message: `Status changed for serial ${serial} (${mark.description}): "${mark.lastStatus}" -> "${result.status}"`, }); } if (result.maintenance?.cancelledOrExpired) { alerts.push({ priority: "high", type: "status-change", message: `CANCELLED OR EXPIRED: serial ${serial} (${mark.description}). This may open the class for filing.`, }); } mark.lastStatus = result.status; mark.lastChecked = todayStr; } } return alerts; } async function searchAndCompare( browser: Browser, baseline: Baseline, ): Promise<{ alerts: Alert[]; searchResults: Record> }> { const alerts: Alert[] = []; const searchResults: Record> = {}; const targetSet = new Set(baseline.targetClasses); for (const { registry, search } of REGISTRY_SEARCHES) { searchResults[registry] = {}; for (const term of baseline.searchTerms) { const key = `${registry}:${term}`; console.log(`[trademark-watch] searching ${REGISTRY_NAMES[registry]} for "${term}"...`); let results: TrademarkResult[] = []; try { results = await search(browser, term); } catch (err: any) { console.error(`[trademark-watch] ${registry} search crashed for "${term}": ${err.message}`); alerts.push({ priority: "info", type: "search-error", message: `${REGISTRY_NAMES[registry]} search failed for "${term}": ${err.message}`, }); } searchResults[registry][term] = results; if (results.length === 0) { console.log(`[trademark-watch] no results for "${term}" on ${registry}`); continue; } console.log(`[trademark-watch] found ${results.length} results for "${term}" on ${registry}`); const previousSerials = new Set(baseline.knownLiveSerials[key] || []); const currentSerials = results.map((r) => r.serial); const newFilings = results.filter((r) => !previousSerials.has(r.serial)); for (const filing of newFilings) { const filingClasses = filing.classes .split(",") .map((c) => c.trim()) .filter(Boolean); const relevantClasses = filingClasses.filter((c) => targetSet.has(c)); if (relevantClasses.length > 0) { alerts.push({ priority: "high", type: "new-filing", message: `NEW in target class(es) [${REGISTRY_NAMES[registry]}]: "${filing.markText}" (${filing.serial}) — Classes ${filing.classes} — ${filing.status} — ${filing.owner}`, }); } else { alerts.push({ priority: "info", type: "new-filing", message: `New filing [${REGISTRY_NAMES[registry]}]: "${filing.markText}" (${filing.serial}) — Classes ${filing.classes || "n/a"} — ${filing.status} — ${filing.owner || "n/a"}`, }); } } baseline.knownLiveSerials[key] = currentSerials; } } return { alerts, searchResults }; } function buildReport( alerts: Alert[], searchResults: Record>, baseline: Baseline, todayStr: string, ): string { const lines: string[] = []; lines.push(`## Trademark Watch Report — ${todayStr}`); lines.push(""); const highAlerts = alerts.filter((a) => a.priority === "high"); const medAlerts = alerts.filter((a) => a.priority === "medium"); const infoAlerts = alerts.filter((a) => a.priority === "info"); if (highAlerts.length > 0) { lines.push("### Action Required"); lines.push(""); for (const a of highAlerts) lines.push(`- ${a.message}`); lines.push(""); } if (medAlerts.length > 0) { lines.push("### Upcoming"); lines.push(""); for (const a of medAlerts) lines.push(`- ${a.message}`); lines.push(""); } lines.push("### Tracked Marks — Current Status"); lines.push(""); for (const [serial, mark] of Object.entries(baseline.trackedMarks)) { const id = mark.source === "madrid" ? `IR ${mark.wipoIR}` : `Serial ${serial}`; lines.push(`- **${id}** (${mark.description}): ${mark.lastStatus || "not yet checked"}`); } lines.push(""); for (const { registry } of REGISTRY_SEARCHES) { const regResults = searchResults[registry]; if (!regResults) continue; lines.push(`### ${REGISTRY_NAMES[registry]}`); lines.push(""); let hasResults = false; for (const term of baseline.searchTerms) { const results = regResults[term] || []; if (results.length > 0) hasResults = true; lines.push(`**"${term}"** — ${results.length} result(s)`); for (const r of results.slice(0, 20)) { const parts = [r.serial, r.status]; if (r.classes) parts.push(`Classes ${r.classes}`); if (r.owner) parts.push(r.owner); lines.push(` - ${r.markText || term} (${parts.join(" — ")})`); } if (results.length > 20) { lines.push(` - ... and ${results.length - 20} more`); } } if (!hasResults) { lines.push("No results or search unavailable."); } lines.push(""); } if (infoAlerts.length > 0) { lines.push("### Notes"); lines.push(""); for (const a of infoAlerts) lines.push(`- ${a.message}`); lines.push(""); } lines.push("---"); const terms = baseline.searchTerms.join(", "); const registries = REGISTRY_SEARCHES.map((r) => REGISTRY_NAMES[r.registry]).join(", "); lines.push(`*Trademark Watch — monitoring ${terms} across ${registries}. Runs 1st and 15th of each month.*`); return lines.join("\n"); } export async function runTrademarkWatch(): Promise { const todayStr = today(); if (fs.existsSync(LAST_SENT)) { const lastRun = fs.readFileSync(LAST_SENT, "utf8").trim(); if (lastRun === todayStr) { console.log("[trademark-watch] already ran today, skipping"); return []; } } console.log(`[trademark-watch] running ${todayStr}`); const baseline = loadBaseline(); const alerts: Alert[] = []; const deadlineAlerts = checkDeadlines(baseline); alerts.push(...deadlineAlerts); console.log(`[trademark-watch] ${deadlineAlerts.length} deadline alert(s)`); const statusAlerts = await checkTrackedMarks(baseline); alerts.push(...statusAlerts); console.log(`[trademark-watch] ${statusAlerts.length} status alert(s)`); let searchResults: Record> = {}; const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] }); try { const searchData = await searchAndCompare(browser, baseline); alerts.push(...searchData.alerts); searchResults = searchData.searchResults; console.log(`[trademark-watch] ${searchData.alerts.length} search alert(s)`); } catch (err: any) { console.error(`[trademark-watch] browser search failed: ${err.message}`); alerts.push({ priority: "info", type: "search-error", message: `Browser search failed: ${err.message}` }); } finally { await browser.close(); } baseline.lastRun = todayStr; saveBaseline(baseline); fs.writeFileSync(LAST_SENT, todayStr); const markdown = buildReport(alerts, searchResults, baseline, todayStr); const highCount = alerts.filter((a) => a.priority === "high").length; const subject = highCount > 0 ? `Trademark Watch: ${highCount} action item(s) — ${todayStr}` : `Trademark Watch: All clear — ${todayStr}`; await sendEmail("ace@manglasabang.com", { to: "junwon@manglasabang.com", subject, markdown, }); console.log(`[trademark-watch] report sent (${alerts.length} total alerts)`); return alerts; } if (require.main === module) { runTrademarkWatch() .then((alerts) => { console.log(`[trademark-watch] done: ${alerts.length} alert(s)`); }) .catch((err) => { console.error("[trademark-watch] fatal:", err); process.exit(1); }); }