mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
423 lines
17 KiB
Python
423 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tiny wrapper for Salesforce documentation extraction.
|
|
|
|
Behavior:
|
|
- If the URL is on help.salesforce.com, automatically route to the dedicated
|
|
Help extractor with shadow DOM heuristics.
|
|
- Otherwise, use a lightweight browser-rendered extractor for official
|
|
Salesforce-owned documentation sites such as developer.salesforce.com,
|
|
architect.salesforce.com, admin.salesforce.com, lightningdesignsystem.com,
|
|
and other supported official documentation hosts.
|
|
|
|
Examples:
|
|
python3 skills/fetching-salesforce-docs/scripts/extract_salesforce_doc.py \
|
|
--url "https://help.salesforce.com/s/articleView?id=service.miaw_security.htm&type=5" \
|
|
--pretty
|
|
|
|
python3 skills/fetching-salesforce-docs/scripts/extract_salesforce_doc.py \
|
|
--url "https://developer.salesforce.com/docs/platform/lwc/guide/use-message-channel-intro.html" \
|
|
--pretty
|
|
|
|
python3 skills/fetching-salesforce-docs/scripts/extract_salesforce_doc.py \
|
|
--url "https://architect.salesforce.com/well-architected/overview" \
|
|
--stealth --pretty
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from typing import Any, Dict
|
|
from urllib.parse import urlparse
|
|
|
|
from runtime_bootstrap import maybe_reexec_in_sf_docs_runtime
|
|
|
|
maybe_reexec_in_sf_docs_runtime(__file__)
|
|
|
|
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
try:
|
|
from playwright_stealth import Stealth
|
|
except ImportError:
|
|
Stealth = None
|
|
|
|
try:
|
|
from playwright_stealth import stealth_sync
|
|
except ImportError:
|
|
stealth_sync = None
|
|
|
|
from extract_help_salesforce import extract as extract_help_salesforce
|
|
|
|
|
|
USER_AGENT = (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/122.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
STRONG_SHELL_TOKENS = [
|
|
"loading",
|
|
"sorry to interrupt",
|
|
"css error",
|
|
"enable javascript",
|
|
"we looked high and low",
|
|
"couldn't find that page",
|
|
"404 error",
|
|
]
|
|
|
|
WEAK_SHELL_TOKENS = [
|
|
"sign in",
|
|
"cookie preferences",
|
|
]
|
|
|
|
OFFICIAL_DOC_EXACT_HOSTS = {
|
|
"salesforce.com",
|
|
"lightningdesignsystem.com",
|
|
}
|
|
|
|
OFFICIAL_DOC_SUFFIXES = (
|
|
".salesforce.com",
|
|
".lightningdesignsystem.com",
|
|
)
|
|
|
|
|
|
def normalize_text(text: str) -> str:
|
|
text = text.replace("\u00a0", " ").replace("\r", "")
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
text = re.sub(r"[ \t]+", " ", text)
|
|
return text.strip()
|
|
|
|
|
|
def looks_like_shell(title: str, text: str) -> bool:
|
|
haystack = f"{title}\n{text}".lower()
|
|
if any(token in haystack for token in STRONG_SHELL_TOKENS):
|
|
return True
|
|
if any(token in haystack for token in WEAK_SHELL_TOKENS):
|
|
return len(text.strip()) < 600
|
|
return False
|
|
|
|
|
|
def apply_stealth(page) -> bool:
|
|
if stealth_sync is not None:
|
|
try:
|
|
stealth_sync(page)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
if Stealth is not None:
|
|
try:
|
|
Stealth().apply_stealth_sync(page)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Extract official Salesforce documentation from a URL")
|
|
parser.add_argument("--url", required=True, help="Official Salesforce doc URL")
|
|
parser.add_argument("--timeout", type=int, default=60, help="Timeout in seconds (default: 60)")
|
|
parser.add_argument("--stealth", action="store_true", help="Best-effort stealth mode for bot-sensitive pages")
|
|
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
|
return parser.parse_args()
|
|
|
|
|
|
def is_official_salesforce_host(host: str) -> bool:
|
|
host = (host or "").lower()
|
|
return host in OFFICIAL_DOC_EXACT_HOSTS or any(host.endswith(suffix) for suffix in OFFICIAL_DOC_SUFFIXES)
|
|
|
|
|
|
def route_kind(url: str) -> str:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
if host.endswith("help.salesforce.com"):
|
|
return "help"
|
|
if is_official_salesforce_host(host):
|
|
return "official"
|
|
raise SystemExit(f"Unsupported host for fetching-salesforce-docs extractor: {host or url}")
|
|
|
|
|
|
def extract_official_salesforce_doc(url: str, timeout_seconds: int, use_stealth: bool = False) -> Dict[str, Any]:
|
|
timeout_ms = timeout_seconds * 1000
|
|
host = (urlparse(url).hostname or "").lower()
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
page = browser.new_page(user_agent=USER_AGENT, viewport={"width": 1440, "height": 1400})
|
|
stealth_used = apply_stealth(page) if use_stealth else False
|
|
|
|
try:
|
|
response = page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
|
|
http_status = response.status if response else None
|
|
page.wait_for_timeout(1500)
|
|
try:
|
|
page.wait_for_load_state("networkidle", timeout=min(timeout_ms, 15000))
|
|
except PlaywrightTimeoutError:
|
|
pass
|
|
|
|
# Wait for content: check both light DOM and shadow DOM content
|
|
is_developer = "developer.salesforce.com" in host
|
|
wait_js = r"""
|
|
() => {
|
|
// Light DOM check (works for most official sites)
|
|
const el = document.querySelector('main, article, [role="main"]');
|
|
const lightText = (el?.innerText || el?.textContent || '').trim();
|
|
if (lightText.length > 200) return true;
|
|
|
|
// Shadow DOM check (needed for developer.salesforce.com)
|
|
function findDeepText(root, depth) {
|
|
if (depth > 5 || !root) return '';
|
|
let best = '';
|
|
const els = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
for (const child of els) {
|
|
if (child.shadowRoot) {
|
|
// Check direct children of shadow root for text
|
|
for (const sc of child.shadowRoot.children) {
|
|
const t = (sc.innerText || '').trim();
|
|
if (t.length > best.length) best = t;
|
|
}
|
|
const deeper = findDeepText(child.shadowRoot, depth + 1);
|
|
if (deeper.length > best.length) best = deeper;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
const shadowText = findDeepText(document, 0);
|
|
return shadowText.length > 200;
|
|
}
|
|
"""
|
|
try:
|
|
page.wait_for_function(
|
|
wait_js,
|
|
timeout=min(timeout_ms, 20000 if is_developer else 15000),
|
|
)
|
|
except PlaywrightTimeoutError:
|
|
pass
|
|
page.wait_for_timeout(500)
|
|
|
|
payload = page.evaluate(
|
|
r"""
|
|
(host) => {
|
|
function normalize(text) {
|
|
return String(text || '')
|
|
.replace(/\u00a0/g, ' ')
|
|
.replace(/\r/g, '')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|
|
|
|
function isVisible(el) {
|
|
if (!el || !el.getBoundingClientRect) return false;
|
|
const rect = el.getBoundingClientRect();
|
|
const style = window.getComputedStyle(el);
|
|
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
|
}
|
|
|
|
function allRoots() {
|
|
const roots = [document];
|
|
const queue = [document];
|
|
while (queue.length) {
|
|
const current = queue.shift();
|
|
if (!current || !current.querySelectorAll) continue;
|
|
const elements = current.querySelectorAll('*');
|
|
for (const el of elements) {
|
|
if (el.shadowRoot) {
|
|
roots.push(el.shadowRoot);
|
|
queue.push(el.shadowRoot);
|
|
}
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
function deepQueryAll(selector) {
|
|
const results = [];
|
|
const seen = new Set();
|
|
for (const root of allRoots()) {
|
|
if (!root.querySelectorAll) continue;
|
|
for (const el of root.querySelectorAll(selector)) {
|
|
if (!seen.has(el)) {
|
|
seen.add(el);
|
|
results.push(el);
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function collectLinks(scope) {
|
|
const urls = new Set();
|
|
const nodes = scope && scope.querySelectorAll ? scope.querySelectorAll('a[href]') : [];
|
|
for (const a of nodes) {
|
|
const href = a.href || a.getAttribute('href') || '';
|
|
if (!href) continue;
|
|
if (href.startsWith('javascript:') || href.startsWith('mailto:')) continue;
|
|
urls.add(href);
|
|
}
|
|
return Array.from(urls);
|
|
}
|
|
|
|
// --- Noise removal: strip cookie/consent overlays before scoring ---
|
|
document.querySelectorAll(
|
|
'#onetrust-banner-sdk, #onetrust-consent-sdk, .onetrust-consent-sdk, ' +
|
|
'[class*="onetrust-pc"], #onetrust-pc-sdk, ' +
|
|
'[id*="cookie-consent"], [class*="cookie-banner"], ' +
|
|
'.evidon-consent-button, #evidon-banner'
|
|
).forEach(function(el) { el.remove(); });
|
|
|
|
const title = document.title || normalize(document.querySelector('title')?.innerText || 'Untitled');
|
|
const childLinks = new Set();
|
|
for (const root of allRoots()) {
|
|
for (const link of collectLinks(root)) childLinks.add(link);
|
|
}
|
|
|
|
const selectorConfigs = [
|
|
{ selector: 'article', strategy: 'article', base: 260 },
|
|
{ selector: 'main', strategy: 'main', base: 220 },
|
|
{ selector: '[role="main"]', strategy: 'role-main', base: 220 },
|
|
{ selector: '.slds-text-longform', strategy: 'longform', base: 200 },
|
|
{ selector: '.markdown-content', strategy: 'markdown-content', base: 190 },
|
|
{ selector: '.content-body', strategy: 'content-body', base: 180 },
|
|
{ selector: '.article-body', strategy: 'article-body', base: 180 },
|
|
{ selector: '.article-content', strategy: 'article-content', base: 180 },
|
|
{ selector: '.post-content', strategy: 'post-content', base: 170 },
|
|
{ selector: '.main-content', strategy: 'main-content', base: 170 },
|
|
{ selector: '.tds-content', strategy: 'tds-content', base: 165 },
|
|
{ selector: '.siteforceContentArea .content', strategy: 'siteforce-content', base: 160 },
|
|
{ selector: 'doc-content-layout', strategy: 'legacy-doc-layout', base: 150 },
|
|
{ selector: 'doc-xml-content', strategy: 'legacy-doc-xml', base: 145 },
|
|
{ selector: 'doc-amf-reference .markdown-content', strategy: 'legacy-amf-markdown', base: 150 },
|
|
{ selector: 'main .content, article .content', strategy: 'nested-content', base: 140 },
|
|
];
|
|
|
|
// --- Host-aware selector overrides for developer.salesforce.com ---
|
|
if (host.includes('developer.salesforce.com')) {
|
|
selectorConfigs.unshift(
|
|
{ selector: 'doc-content', strategy: 'dev-doc-content', base: 295 },
|
|
{ selector: '#maincontent', strategy: 'dev-maincontent', base: 290 },
|
|
{ selector: '.doc-body', strategy: 'dev-doc-body', base: 285 },
|
|
{ selector: '#topic-content', strategy: 'dev-topic-content', base: 285 },
|
|
{ selector: '.topicContent', strategy: 'dev-topic', base: 280 },
|
|
{ selector: '[class*="docContent"]', strategy: 'dev-doc-content-class', base: 275 },
|
|
{ selector: 'doc-xml-content .content', strategy: 'dev-xml-content', base: 270 },
|
|
);
|
|
}
|
|
|
|
// Extract text from an element, including shadow root content
|
|
function extractText(el) {
|
|
let text = (el.innerText || el.textContent || '').trim();
|
|
// If element is a shadow host with no slotted light DOM text,
|
|
// gather text from its shadow root's children
|
|
if (text.length < 200 && el.shadowRoot) {
|
|
let shadowText = '';
|
|
for (const child of el.shadowRoot.children) {
|
|
shadowText += (child.innerText || child.textContent || '') + '\n';
|
|
}
|
|
if (shadowText.trim().length > text.length) {
|
|
text = shadowText.trim();
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
const candidates = [];
|
|
for (const cfg of selectorConfigs) {
|
|
const nodes = deepQueryAll(cfg.selector);
|
|
for (const node of nodes) {
|
|
if (!isVisible(node)) continue;
|
|
const text = normalize(extractText(node));
|
|
if (text.length < 200) continue;
|
|
let score = cfg.base + Math.min(text.length, 5000) / 30;
|
|
const lowered = text.toLowerCase();
|
|
if (lowered.includes(title.toLowerCase())) score += 50;
|
|
if (lowered.includes('table of contents')) score -= 80;
|
|
if (lowered.includes('cookie preferences')) score -= 120;
|
|
if (lowered.includes('sign in')) score -= 120;
|
|
candidates.push({
|
|
strategy: cfg.strategy,
|
|
selector: cfg.selector,
|
|
score,
|
|
text,
|
|
links: collectLinks(node).slice(0, 200),
|
|
});
|
|
}
|
|
}
|
|
|
|
const bodyText = normalize(document.body?.innerText || '');
|
|
if (bodyText.length >= 200) {
|
|
candidates.push({
|
|
strategy: 'body',
|
|
selector: 'body',
|
|
score: Math.min(bodyText.length, 5000) / 50,
|
|
text: bodyText,
|
|
links: Array.from(childLinks).slice(0, 200),
|
|
});
|
|
}
|
|
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
const best = candidates[0] || null;
|
|
|
|
return {
|
|
url: window.location.href,
|
|
title,
|
|
strategy: best ? best.strategy : 'none',
|
|
selector: best ? best.selector : null,
|
|
text: best ? best.text : '',
|
|
contentLinks: best ? best.links : [],
|
|
childLinks: Array.from(childLinks).slice(0, 200),
|
|
candidateCount: candidates.length,
|
|
};
|
|
}
|
|
""",
|
|
host,
|
|
)
|
|
|
|
text = normalize_text(payload.get("text", ""))
|
|
likely_shell = looks_like_shell(payload.get("title", ""), text)
|
|
ok = bool(text) and len(text) >= 300 and not likely_shell
|
|
|
|
return {
|
|
"ok": ok,
|
|
"url": payload.get("url", url),
|
|
"httpStatus": http_status,
|
|
"title": payload.get("title") or "Untitled",
|
|
"host": host,
|
|
"hostKind": "official-salesforce",
|
|
"strategy": payload.get("strategy"),
|
|
"selector": payload.get("selector"),
|
|
"likelyShell": likely_shell,
|
|
"stealthRequested": use_stealth,
|
|
"stealthAvailable": stealth_sync is not None or Stealth is not None,
|
|
"stealthUsed": stealth_used,
|
|
"text": text,
|
|
"contentLinks": payload.get("contentLinks", []),
|
|
"childLinks": payload.get("childLinks", []),
|
|
"candidateCount": payload.get("candidateCount", 0),
|
|
}
|
|
finally:
|
|
page.close()
|
|
browser.close()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
kind = route_kind(args.url)
|
|
|
|
if kind == "help":
|
|
result = extract_help_salesforce(args.url, args.timeout, use_stealth=args.stealth)
|
|
result["routedVia"] = "extract_help_salesforce"
|
|
result.setdefault("hostKind", "help")
|
|
else:
|
|
result = extract_official_salesforce_doc(args.url, args.timeout, use_stealth=args.stealth)
|
|
result["routedVia"] = "generic_official_salesforce_extractor"
|
|
|
|
dump = json.dumps(result, indent=2 if args.pretty else None)
|
|
print(dump)
|
|
return 0 if result.get("ok") else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|