Press Clip You are press-clip , the Newsjack skill that turns a live article into the artifact a PR agency hands a client: proof their coverage ran, in a form the client trusts on sight. Understand this first, because it's the mistake that ruins clips: a press clip must look like the publication it came from. The outlet's logo, masthead, real fonts, the article's photos, the familiar layout — those are not decoration, they are the trust signals that tell a client "yes, this really ran in this outlet." A clip rebuilt as plain text reads like a memo and convinces no one. So you do not rebuild t…

);\n // the masthead sits above the headline; banners above it can push it well below 300 px\n const h1 = document.querySelector('[data-pc-h1]') || document.querySelector('h1');\n const limit = Math.min(900, Math.max(300, h1 ? h1.getBoundingClientRect().top : 600));\n const markWidth = (a) => { const m = a.querySelector('svg, img'); return m ? m.getBoundingClientRect().width : 0; };\n const brandCands = [\n ...[...document.querySelectorAll('a')].filter((a) => {\n const href = a.getAttribute('href') || ''; const r = a.getBoundingClientRect();\n return (href === '/' || originRe.test(href)) && r.top \u003c limit && r.width > 60 && r.left > -1 && r.right \u003c innerWidth + 1;\n }).sort((a, b) => markWidth(b) - markWidth(a)),\n ...document.querySelectorAll('[class*=\"site-logo\" i], [class*=\"masthead\" i], [class*=\"navbar-brand\" i], [class*=\"logo\" i]'),\n ];\n const plateOf = (el) => {\n for (let p = el; p && p !== document.documentElement; p = p.parentElement) {\n const m = getComputedStyle(p).backgroundColor.match(/rgba?\\(([^)]+)\\)/);\n if (!m) continue;\n const [r, g, b, a = 1] = m[1].split(',').map((v) => parseFloat(v));\n if (a \u003c 0.5) continue;\n const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n return lum > 0.94 ? null : `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;\n }\n return null;\n };\n const SHAPES = 'path, rect, circle, ellipse, polygon, polyline, line, text, tspan, use, g';\n const inlineSvg = (svg) => {\n const copy = svg.cloneNode(true);\n const src = [svg, ...svg.querySelectorAll(SHAPES)], dst = [copy, ...copy.querySelectorAll(SHAPES)];\n src.forEach((el, i) => {\n const cs = getComputedStyle(el), d = dst[i];\n if (!d) return;\n for (const prop of ['fill', 'stroke', 'fill-opacity', 'stroke-width', 'color']) {\n const v = cs.getPropertyValue(prop);\n if (v) d.style.setProperty(prop, v);\n }\n });\n // inline the symbols that sprite \u003cuse> elements point at, so the copy survives isolation\n const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');\n copy.querySelectorAll('use').forEach((u) => {\n const ref = (u.getAttribute('href') || u.getAttribute('xlink:href') || '').split('#')[1];\n const target = ref && document.getElementById(ref);\n if (target && !copy.querySelector('#' + CSS.escape(ref))) defs.appendChild(target.cloneNode(true));\n });\n if (defs.childNodes.length) copy.prepend(defs);\n const r = svg.getBoundingClientRect();\n if (!copy.getAttribute('viewBox') && r.width && r.height) copy.setAttribute('viewBox', `0 0 ${r.width} ${r.height}`);\n return copy.outerHTML;\n };\n for (const c of brandCands) {\n const img = c.matches('img') ? c : c.querySelector('img');\n if (img) { const s = img.currentSrc || img.src || ''; if (s && !badImg.test(s)) { logoSrc = s; plate = plateOf(img); break; } }\n const svg = c.matches('svg') ? c : c.querySelector('svg');\n if (svg && svg.getBoundingClientRect().width >= 60) {\n logoSvg = inlineSvg(svg);\n plate = plateOf(svg);\n color = getComputedStyle(svg).color;\n animated = svg.getAnimations({ subtree: true }).length > 0 || !!svg.querySelector('animate, animateTransform, animateMotion, set');\n break;\n }\n }\n return { logoSrc, logoSvg, plate, color, animated };\n}\n\n// Last-resort logo: og:logo or the site icon. Lower fidelity than a masthead, but\n// still the outlet's own mark — better than a text wordmark. Returns an absolute URL.\nfunction detectLogoFallback() {\n const meta = (sel, attr = 'content') => { const e = document.querySelector(sel); if (!e) return ''; const v = (e.getAttribute(attr) || '').trim(); return v ? new URL(v, location.href).href : ''; };\n return meta('meta[property=\"og:logo\"]') || meta('link[rel*=\"icon\"]', 'href');\n}\n\nfunction surgery({ section, keep, drop, logoSrc, logoSvg, plate, color, outletName }) {\n const articleRoot = document.querySelector('[data-pc-root]') || document.body;\n\n const keepSel = (keep || '').split(',').map((s) => s.trim()).filter(Boolean);\n const protectedEls = new Set();\n keepSel.forEach((sel) => document.querySelectorAll(sel).forEach((n) => protectedEls.add(n)));\n const isProtected = (n) => { for (let p = n; p; p = p.parentElement) if (protectedEls.has(p)) return true; return false; };\n\n // Isolate the story WITHOUT guessing class names: hide everything that is not on the path from\n // \u003cbody> down to the article. The site header, nav, sidebars, footer, and recirculation rails\n // are all siblings of the article's ancestor chain, so they fall away — while legitimate layout\n // wrappers around the body (even ones classed \"...--has-sidebar\") are on the path and survive.\n // This avoids the brittle \"[class*=sidebar]\" substring match that deletes real content on some\n // templates. (Broad class-keyword removal does not generalize; isolation by structure does.)\n for (let node = articleRoot; node && node.parentElement && node !== document.body; node = node.parentElement) {\n for (const sib of [...node.parentElement.children]) {\n if (sib === node || sib.contains(articleRoot) || isProtected(sib)) continue;\n if (sib.tagName === 'STYLE' || sib.tagName === 'SCRIPT' || sib.tagName === 'LINK') continue;\n sib.remove();\n }\n }\n\n // Inside the article, remove only high-confidence junk (never broad layout words): ad slots and\n // embeds, consent/overlay dialogs, in-article newsletter sign-ups and tables of contents.\n const HARD_JUNK = [\n 'iframe', 'ins.adsbygoogle', '.adsbygoogle', 'amp-ad', '[id^=\"div-gpt\"]', '[id*=\"google_ads\"]',\n '[data-ad]', '[aria-label*=\"advertisement\" i]', '[role=\"dialog\"]', '[aria-modal=\"true\"]',\n '[class*=\"newsletter\" i]', '[class*=\"ez-toc\" i]', '[class*=\"table-of-contents\" i]',\n ];\n [...HARD_JUNK, ...(drop || '').split(',').map((s) => s.trim()).filter(Boolean)].forEach((sel) => {\n try { document.querySelectorAll(sel).forEach((n) => { if (!n.contains(articleRoot) && !isProtected(n)) n.remove(); }); } catch {}\n });\n\n // Fixed and sticky chrome outside the article (video players, \"keep scrolling\" bars, sticky\n // headers, consent overlays) would float over captures far down the page: hide it, unstick\n // the rest, and undo the scroll lock modals put on \u003chtml>/\u003cbody>. Pure structure, no names.\n const vw = innerWidth, vh = innerHeight;\n for (const el of document.querySelectorAll('body *')) {\n if (el.closest('.pc-header')) continue;\n const cs = getComputedStyle(el);\n if (cs.position === 'fixed') {\n if (el.contains(articleRoot)) el.style.setProperty('position', 'static', 'important');\n else if (!isProtected(el)) el.style.setProperty('display', 'none', 'important');\n } else if (cs.position === 'sticky') {\n el.style.setProperty('position', 'static', 'important');\n } else if (cs.position === 'absolute' && !articleRoot.contains(el) && !el.contains(articleRoot)) {\n const r = el.getBoundingClientRect(), z = parseInt(cs.zIndex, 10) || 0;\n if (z > 1000 && r.width * r.height > 0.3 * vw * vh) el.style.setProperty('display', 'none', 'important');\n }\n }\n // Release scroll locks (consent dialogs pin \u003cbody> with position:fixed; overflow:hidden, which\n // also cuts the PDF to one page). Inline !important beats any stylesheet rule, print included.\n for (const el of [document.documentElement, document.body]) {\n for (const [prop, value] of [['overflow', 'visible'], ['position', 'static'], ['height', 'auto'], ['max-height', 'none'], ['top', 'auto']]) el.style.setProperty(prop, value, 'important');\n }\n\n // widen the main column in case a removed sidebar left the article in a narrow grid track\n document.querySelectorAll('[class*=\"span8\"], [class*=\"main-content\"], [class*=\"content-area\"]')\n .forEach((n) => { n.style.width = '100%'; n.style.maxWidth = '100%'; n.style.flex = '0 0 100%'; });\n\n // --- isolate one roundup section, if asked ---\n // Works in document order, so it also finds the section when headings and entries live in\n // different wrappers: keep the lead (everything before the first section heading), keep\n // the client's section up to the next heading of the same or a higher level, drop the rest.\n let sectionFound = null;\n if (section) {\n const content = articleRoot;\n const h1 = content.querySelector('[data-pc-h1]') || content.querySelector('h1');\n const heads = [...content.querySelectorAll('h1,h2,h3,h4')].filter((h) => h !== h1 && (!h1 || (h1.compareDocumentPosition(h) & Node.DOCUMENT_POSITION_FOLLOWING)));\n const norm = (s) => (s || '').trim().toLowerCase();\n const target = norm(section).slice(0, 8);\n const start = heads.find((h) => norm(h.textContent).startsWith(target));\n sectionFound = !!start;\n if (start) {\n start.setAttribute('data-pc-section', '');\n const rank = (h) => Number(h.tagName[1]);\n const after = (h) => start.compareDocumentPosition(h) & Node.DOCUMENT_POSITION_FOLLOWING;\n const boundaries = heads.filter((h) => rank(h) \u003c= rank(start));\n const end = boundaries.find((h) => after(h) && !start.contains(h));\n if (end) { const r = document.createRange(); r.setStartBefore(end); r.setEnd(content, content.childNodes.length); r.deleteContents(); }\n const first = boundaries[0];\n if (first && first !== start) { const r = document.createRange(); r.setStartBefore(first); r.setEndBefore(start); r.deleteContents(); }\n }\n }\n\n // Sweep visual dead space left by request-level blocking: a blocked ad slot or emptied embed\n // (e.g. a removed tweet \u003cblockquote>) becomes an empty box that still takes height. Remove\n // elements INSIDE the article that render with size but carry no text, no media, and no caption.\n // This is generic — it keys on \"renders empty\", not on any publisher's class names. A genuinely\n // failed first-party image still has its \u003cimg>/\u003cfigure> in the DOM, so it counts as media and is\n // kept; we also log everything stripped so a real photo can never vanish silently.\n const stripped = [];\n const MEDIA = 'img, picture, video, svg, iframe, embed, object, figcaption, [class*=\"caption\" i]';\n [...articleRoot.querySelectorAll('div, aside, section, blockquote, ins, figure')].forEach((el) => {\n if (!el.isConnected || isProtected(el)) return; // already gone with an ancestor, or kept\n const r = el.getBoundingClientRect();\n if (r.height \u003c 8 || r.width \u003c 8) return; // not occupying visible space\n if ((el.innerText || '').trim().length || el.querySelector(MEDIA)) return; // has real content\n const cls = (typeof el.className === 'string' && el.className.trim()) ? '.' + el.className.trim().split(/\\s+/).join('.') : '';\n stripped.push(el.tagName.toLowerCase() + cls + ' [' + Math.round(r.width) + '×' + Math.round(r.height) + ']');\n el.remove();\n });\n\n // Sweep link farms left inside the article: \"most viewed\" rails, related-story lists, tag\n // clouds. Structure only: a block with several links whose text is almost all link text and\n // that holds no real prose. The widest such block is removed (and logged), never the headline.\n const linkLen = (el) => [...el.querySelectorAll('a')].reduce((n, a) => n + (a.innerText || '').trim().length, 0);\n const prose = (el) => [...el.querySelectorAll('p')].some((p) => ((p.innerText || '').trim().length - linkLen(p)) > 120);\n const isFarm = (el) => {\n if (!el.isConnected || isProtected(el) || el.querySelector('h1') || el.contains(articleRoot)) return false;\n const text = (el.innerText || '').trim().length;\n return text >= 60 && el.querySelectorAll('a').length >= 3 && linkLen(el) >= 0.8 * text && !prose(el);\n };\n [...articleRoot.querySelectorAll('div, aside, section, ul, ol, nav')].forEach((el) => {\n if (!isFarm(el) || (el.parentElement && el.parentElement !== articleRoot && isFarm(el.parentElement))) return;\n const r = el.getBoundingClientRect();\n const cls = (typeof el.className === 'string' && el.className.trim()) ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : '';\n stripped.push('link list ' + el.tagName.toLowerCase() + cls + ' [' + Math.round(r.width) + '×' + Math.round(r.height) + ']');\n el.remove();\n });\n\n // --- clip header: the outlet LOGO only (the key trust signal), shown large above the article.\n // The mark is never recoloured: it keeps the colours it had on the site and, when the site\n // showed it on a coloured header, it sits on a plate of that same colour. ---\n const esc = (s) => (s || '').replace(/&/g, '&').replace(/\u003c/g, '<').replace(/\"/g, '"');\n const LOGO_H = 64;\n const sizer = document.createElement('style');\n sizer.textContent = '.pc-logo svg{height:' + LOGO_H + 'px !important;width:auto !important;display:block}';\n document.head && document.head.appendChild(sizer);\n const plateCss = plate ? 'background:' + plate + ';padding:14px 24px;' : '';\n const logoImg = logoSvg\n ? '\u003cspan class=\"pc-logo\" style=\"display:inline-block;height:' + LOGO_H + 'px;line-height:0;color:' + (color || '#111') + '\">' + logoSvg + '\u003c/span>'\n : logoSrc\n ? '\u003cimg class=\"pc-logo\" src=\"' + esc(logoSrc) + '\" alt=\"' + esc(outletName) + '\" style=\"height:' + LOGO_H + 'px;max-width:420px;width:auto;object-fit:contain;display:block\">'\n : '\u003cspan class=\"pc-logo pc-wordmark\" style=\"font:700 30px/1 Georgia,serif;color:#111\">' + esc(outletName) + '\u003c/span>';\n const header = document.createElement('div');\n header.className = 'pc-header';\n header.style.cssText = 'background:#fff;padding:4px 4px 16px;margin:0 0 14px;display:flex;justify-content:center;align-items:center';\n header.innerHTML = '\u003cspan class=\"pc-plate\" style=\"display:inline-flex;align-items:center;line-height:0;' + plateCss + '\">' + logoImg + '\u003c/span>';\n document.body.prepend(header);\n return { stripped, sectionFound };\n}\n\n// Centre the stamped logo over the article's own content column rather than the window, so\n// it sits above the story once side rails are gone.\nfunction alignStamp() {\n const root = document.querySelector('[data-pc-root]') || document.body;\n const header = document.querySelector('.pc-header'), plate = document.querySelector('.pc-plate');\n if (!header || !plate) return;\n let x0 = Infinity, x1 = -Infinity;\n for (const el of root.querySelectorAll('h1, h2, h3, p, figure, img, picture, video, blockquote')) {\n const r = el.getBoundingClientRect();\n if (r.width \u003c 2 || r.height \u003c 2 || r.left \u003c -1 || r.right > innerWidth + 1) continue; // skip off-canvas slides\n x0 = Math.min(x0, r.left); x1 = Math.max(x1, r.right);\n }\n if (!Number.isFinite(x0)) return;\n const w = plate.getBoundingClientRect().width;\n header.style.justifyContent = 'flex-start';\n header.style.paddingLeft = Math.max(4, Math.round((x0 + x1) / 2 - w / 2)) + 'px';\n}\n\n// Freeze the page: finish finite animations, pause infinite ones at their current frame\n// (cancel() would drop them to a base style), pause SMIL clocks and videos. The stamped\n// logo is left alone here; its frame is chosen separately.\nfunction freezePage() {\n for (const a of document.getAnimations()) {\n const target = a.effect && a.effect.target;\n if (target && target.closest && target.closest('.pc-header')) continue;\n try { Number.isFinite(a.effect?.getComputedTiming().endTime) ? a.finish() : a.pause(); } catch {}\n }\n document.querySelectorAll('svg').forEach((s) => { if (!s.closest('.pc-header')) { try { s.pauseAnimations(); } catch {} } });\n document.querySelectorAll('video, audio').forEach((v) => { try { v.pause(); } catch {} });\n}\n\n// Copying an animated SVG restarts its animations, so a stamped copy is caught mid-draw.\n// Seek every animation of the stamped logo to its first or last frame and hold it there.\nfunction setLogoFrame(which) {\n const svg = document.querySelector('.pc-logo svg');\n if (!svg) return false;\n for (const a of svg.getAnimations({ subtree: true })) {\n try {\n a.pause();\n const end = a.effect.getComputedTiming().endTime;\n a.currentTime = which === 'start' ? 0 : (Number.isFinite(end) ? end : 0);\n } catch {}\n }\n try { svg.pauseAnimations(); svg.setCurrentTime(which === 'start' ? 0 : 1e6); } catch {}\n return true;\n}\n\n// Width of the non-transparent pixels of a PNG (measured in the page, no image library).\nasync function inkWidth(b64) {\n const img = new Image(); img.src = 'data:image/png;base64,' + b64; await img.decode();\n const c = new OffscreenCanvas(img.naturalWidth, img.naturalHeight); const g = c.getContext('2d');\n g.drawImage(img, 0, 0);\n const { data, width, height } = g.getImageData(0, 0, c.width, c.height);\n let x0 = width, x1 = -1;\n for (let y = 0; y \u003c height; y += 2) for (let x = 0; x \u003c width; x++) if (data[(y * width + x) * 4 + 3] > 24) { if (x \u003c x0) x0 = x; if (x > x1) x1 = x; }\n return x1 \u003c 0 ? 0 : x1 - x0 + 1;\n}\n\n// Locate every mention of the client's terms inside the pinned article. Walks text nodes,\n// matches case-sensitively across inline elements (never across blocks), expands each hit to\n// its sentence and key clause, and returns page-space rectangles plus the features used to\n// score the hit. Ranges are kept on window so rectangles can be re-measured after capture.\nfunction locateMentions({ terms, ambiguous }) {\n const root = document.querySelector('[data-pc-root]') || document.body;\n const BLOCK = 'p,li,blockquote,h1,h2,h3,h4,h5,h6,figcaption,td,th,dd,dt,pre,summary,caption';\n const SKIP = 'script,style,noscript,template,svg,[hidden],[aria-hidden=\"true\"],.pc-header';\n const norm = (ch) => ch.replace(/[\\u2018\\u2019\\u02bc\\u2032]/g, \"'\").replace(/[\\u00ad\\u200b-\\u200d\\u2060\\ufeff]/g, '').replace(/[\\u00a0\\u2007\\u202f]/g, ' ').replace(/[\\u2010\\u2011]/g, '-');\n const blocks = []; let cur = null;\n const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n) {\n const p = n.parentElement;\n if (!p || p.closest(SKIP)) return NodeFilter.FILTER_REJECT;\n const cs = getComputedStyle(p);\n if (cs.display === 'none' || cs.visibility === 'hidden') return NodeFilter.FILTER_REJECT;\n return NodeFilter.FILTER_ACCEPT;\n } });\n for (let n; (n = tw.nextNode());) {\n const el = n.parentElement.closest(BLOCK) || n.parentElement;\n if (!cur || cur.el !== el) { cur = { el, text: '', map: [] }; blocks.push(cur); }\n const v = n.nodeValue;\n for (let i = 0; i \u003c v.length; i++) for (const ch of norm(v[i])) { cur.text += ch; cur.map.push([n, i]); }\n }\n const esc = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}');\n const alts = terms.map((t) => t.split(/\\s+/).map((w) => esc(norm(w))).join('\\\\s+'));\n const re = new RegExp('(?\u003c![\\\\p{L}\\\\p{N}])(?:' + alts.join('|') + \")(?:'s)?(?![\\\\p{L}\\\\p{N}])\", 'gu');\n const segmenter = new Intl.Segmenter(document.documentElement.lang || 'en', { granularity: 'sentence' });\n const ABBR = /(?:\\b[A-Z]|\\b(?:Mr|Mrs|Ms|Dr|Prof|St|vs|etc|Inc|Co|Corp|Ltd|No|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec|U\\.S|e\\.g|i\\.e))\\.\\s*$/;\n const paragraphs = [...root.querySelectorAll('p')];\n const sectionHead = root.querySelector('[data-pc-section]');\n const pageRects = (range) => [...range.getClientRects()].filter((q) => q.width > 1 && q.height > 1)\n .map((q) => ({ x: q.left + scrollX, y: q.top + scrollY, w: q.width, h: q.height }));\n const rangeOf = (blk, a, b) => {\n const s = blk.map[a], e = blk.map[b - 1];\n const r = document.createRange(); r.setStart(s[0], s[1]); r.setEnd(e[0], e[1] + 1); return r;\n };\n const trimSpan = (text, a, b) => { while (a \u003c b && /\\s/.test(text[a])) a++; while (b > a && /\\s/.test(text[b - 1])) b--; return [a, b]; };\n const host = location.hostname.replace(/^www\\./, '');\n const hits = [];\n window.__pcHits = [];\n for (const blk of blocks) {\n if (!root.contains(blk.el) && blk.el !== root) continue;\n // sentence spans, re-joining splits after abbreviations (\"U.S.\", \"Inc.\")\n const sents = [];\n for (const s of segmenter.segment(blk.text)) {\n const prev = sents[sents.length - 1];\n if (prev && ABBR.test(blk.text.slice(prev[0], prev[1]))) prev[1] = s.index + s.segment.length;\n else sents.push([s.index, s.index + s.segment.length]);\n }\n for (const m of blk.text.matchAll(re)) {\n const start = m.index, end = m.index + m[0].length;\n const [sa0, sb0] = sents.find(([a, b]) => start >= a && start \u003c b) || [0, blk.text.length];\n const [sa, sb] = trimSpan(blk.text, sa0, sb0);\n const sentence = blk.text.slice(sa, sb).replace(/\\s+/g, ' ').trim();\n // key clause: for a long sentence, the verbatim span around the term that reads on its own\n // (30-80 characters, the shorter the better), bounded by punctuation or before a joining\n // word. The reel highlights this span and its phone formats zoom to fit it, so a shorter\n // clause reads larger and long sentences never need long holds.\n let ca = sa, cb = sb;\n if (sb - sa > 60) {\n const cuts = [{ at: sa, hard: true }];\n let depth = 0, quoted = false;\n for (let i = sa; i \u003c sb; i++) {\n const ch = blk.text[i];\n if (ch === '(' || ch === '[') depth++;\n else if ((ch === ')' || ch === ']') && depth) depth--;\n else if (ch === '\"') quoted = !quoted;\n else if (ch === '\\u201c') quoted = true;\n else if (ch === '\\u201d') quoted = false;\n if (depth || quoted) continue; // never cut inside brackets or quotes\n if (/[,;:\\u2013\\u2014]/.test(ch) && /\\s/.test(blk.text[i + 1] || '')) cuts.push({ at: i + 1, hard: true });\n else if (/\\s/.test(ch) && /^(?:and|but|or|so|yet|which|while|whereas|because|though|although|from|with|for|to|that|than|as|in|on|at|by|after|before|since|without)\\s/i.test(blk.text.slice(i + 1, i + 12))) cuts.push({ at: i + 1, hard: false });\n }\n cuts.push({ at: sb, hard: true });\n const trim = (a, b) => {\n while (a \u003c b && /[\\s,;:]/.test(blk.text[a])) a++;\n for (let guard = 0; guard \u003c 4; guard++) {\n while (b > a && /[,;:\\s]$/.test(blk.text.slice(a, b))) b--;\n const tail = /\\s(?:and|but|or|so|yet|which|while)$/i.exec(blk.text.slice(a, b));\n if (!tail || b - tail[0].length \u003c= end) break;\n b -= tail[0].length;\n }\n return [a, b];\n };\n let best = null;\n for (const c0 of cuts) {\n if (c0.at > start) break;\n for (const c1 of cuts) {\n if (c1.at \u003c end) continue;\n const [a, b] = trim(c0.at, c1.at);\n const len = b - a;\n if (len > 80 || b \u003c end || a > start) continue;\n const score = (c0.hard ? 0 : -1) + (c1.hard ? 0 : -2) - (len \u003c 30 ? 3 : 0) - Math.abs(len - 40) / 30;\n if (!best || score > best.score) best = { a, b, score };\n }\n }\n if (best) [ca, cb] = [best.a, best.b];\n }\n const clause = blk.text.slice(ca, cb).replace(/\\s+/g, ' ').trim();\n const termRange = rangeOf(blk, start, end);\n const termRects = pageRects(termRange);\n if (!termRects.length) continue; // visually hidden\n const hostEl = blk.map[start][0].parentElement;\n const link = hostEl.closest('a[href]');\n let sameSiteLink = false;\n if (link) { try { const u = new URL(link.href, location.href); sameSiteLink = u.hostname.replace(/^www\\./, '') === host && u.pathname !== location.pathname; } catch {} }\n const blockText = (blk.el.innerText || '').trim();\n const linkText = [...blk.el.querySelectorAll('a')].reduce((n, a) => n + (a.innerText || '').trim().length, 0);\n const before = blk.text.slice(sa, start);\n const words = blockText.split(/\\s+/).filter((w) => /\\p{L}/u.test(w));\n const heading = /^H[1-6]$/.test(blk.el.tagName);\n const br = blk.el.getBoundingClientRect();\n const termText = m[0].replace(/'s$/, '').replace(/\\s+/g, ' ');\n const sentenceRange = rangeOf(blk, sa, sb), clauseRange = rangeOf(blk, ca, cb);\n window.__pcHits.push({ termRange, sentenceRange, clauseRange });\n hits.push({\n index: hits.length,\n term: terms.find((t) => t.replace(/\\s+/g, ' ') === termText) || termText,\n text: m[0].replace(/\\s+/g, ' '),\n sentence, clause,\n block: blk.el.tagName,\n ambiguous: ambiguous.includes(termText),\n sentenceInitial: !/[\\p{L}\\p{N}]/u.test(before),\n titleCaseHeading: heading && words.length > 1 && words.filter((w) => /^\\p{Lu}/u.test(w)).length / words.length >= 0.6,\n inLink: !!link, sameSiteLink,\n linkDense: blockText.length > 0 && linkText / blockText.length > 0.6,\n inAside: !!hostEl.closest('aside, nav, footer, [role=\"complementary\"], [role=\"navigation\"]'),\n inCaption: !!hostEl.closest('figcaption, figure'),\n inHeadline: !!hostEl.closest('h1'),\n inHeading: heading,\n attributedQuote: /[\"\\u201c\\u201d]/.test(sentence) && /\\b(said|says|according to|told)\\b/i.test(sentence),\n paragraphIndex: paragraphs.indexOf(blk.el),\n sectionScoped: !!sectionHead,\n inSection: !!sectionHead && (sectionHead.contains(blk.el) || !!(sectionHead.compareDocumentPosition(blk.el) & Node.DOCUMENT_POSITION_FOLLOWING)),\n rects: {\n term: termRects,\n sentence: pageRects(sentenceRange),\n clause: pageRects(clauseRange),\n paragraph: { x: br.left + scrollX, y: br.top + scrollY, w: br.width, h: br.height },\n },\n context: (() => {\n const prev = blk.el.previousElementSibling, next = blk.el.nextElementSibling;\n const pr = prev ? prev.getBoundingClientRect() : br, nr = next ? next.getBoundingClientRect() : br;\n return { top: Math.min(pr.top, br.top) + scrollY, bottom: Math.max(nr.bottom, br.bottom) + scrollY };\n })(),\n });\n }\n }\n return hits;\n}\n\n// Layout facts measured as the last step before capture.\nfunction measureLayout() {\n const pr = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height }; };\n const root = document.querySelector('[data-pc-root]') || document.body;\n const h1 = document.querySelector('[data-pc-h1]') || root.querySelector('h1') || document.querySelector('h1');\n const rr = pr(root), hr = pr(h1);\n let hero = null;\n if (hr) {\n for (const el of root.querySelectorAll('img, picture, video, figure')) {\n const r = pr(el);\n if (r.y >= hr.y - 40 && r.y \u003c hr.y + hr.h + 1400 && r.w >= 0.4 * rr.w && r.h > 120) { hero = r; break; }\n }\n }\n // horizontal extent of the article's real content (the root is often a full-width grid\n // with empty gutters once side rails are gone)\n let cx0 = Infinity, cx1 = -Infinity;\n for (const el of root.querySelectorAll('h1, h2, h3, p, figure, img, picture, video, blockquote, ul, ol, table, time, address')) {\n const r = el.getBoundingClientRect();\n if (r.width \u003c 2 || r.height \u003c 2 || r.left \u003c -1 || r.right > innerWidth + 1) continue; // skip off-canvas slides\n cx0 = Math.min(cx0, r.left + scrollX); cx1 = Math.max(cx1, r.right + scrollX);\n }\n return {\n root: rr, h1: hr, hero,\n content: Number.isFinite(cx0) ? { x0: cx0, x1: cx1 } : null,\n header: pr(document.querySelector('.pc-header')),\n plate: pr(document.querySelector('.pc-plate')),\n logo: pr(document.querySelector('.pc-logo')),\n docHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),\n h1Count: document.querySelectorAll('h1').length,\n path: location.pathname,\n };\n}\n\n// Wait until the article stops moving (late images, embeds, web fonts) before measuring.\nasync function waitForStableLayout() {\n const root = document.querySelector('[data-pc-root]') || document.body;\n const sample = () => { const r = root.getBoundingClientRect(); return `${Math.round(r.top + scrollY)}|${Math.round(r.height)}|${document.documentElement.scrollHeight}`; };\n let last = sample();\n for (let i = 0; i \u003c 12; i++) {\n await new Promise((r) => setTimeout(r, 400));\n const now = sample();\n if (now === last) return true;\n last = now;\n }\n return false;\n}\n\nfunction remeasure(index) {\n const h = (window.__pcHits || [])[index];\n const root = document.querySelector('[data-pc-root]') || document.body;\n const r = root.getBoundingClientRect();\n const t = h ? [...h.termRange.getClientRects()][0] : null;\n return { root: { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height }, term: t ? { x: t.left + scrollX, y: t.top + scrollY } : null };\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\n\nasync function main() {\n const args = parseArgs(process.argv.slice(2));\n const { url, out, section, preview, keep, drop, root, logo: logoArg } = args;\n if (!url || !out) { console.error('need --url and --out'); process.exit(1); }\n const terms = parseMentionTerms(args.mention);\n const assetsDir = args.assets ? resolve(args.assets) : '';\n const scale = args.scale === undefined ? 2 : Number(args.scale);\n if (![2, 3].includes(scale)) { console.error('--scale must be 2 or 3'); process.exit(1); }\n const pick = args.pick === undefined ? 1 : Number(args.pick);\n if (!Number.isInteger(pick) || pick \u003c 1) { console.error('--pick must be a whole number from 1'); process.exit(1); }\n\n const CHROME = args.chrome\n || process.env.PRESS_CLIP_CHROME\n || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';\n const chromium = loadChromium();\n const warnings = [];\n const warn = (code, detail) => { warnings.push({ code, detail }); console.warn(`WARNING (${code}): ${detail}`); };\n\n const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--hide-scrollbars', '--force-color-profile=srgb'] });\n try {\n const contextOptions = { viewport: { width: 1180, height: 1600 }, deviceScaleFactor: assetsDir ? scale : 2, reducedMotion: 'reduce', serviceWorkers: 'block', colorScheme: 'light' };\n const context = await browser.newContext(contextOptions);\n await context.addInitScript(pinPathInit);\n const page = await context.newPage();\n\n // Request rules: ad/recirc networks never load; once the article has settled, same-site\n // scripts may not fetch whole HTML documents (that is how next-article loaders append\n // the following story). JSON/API calls still pass.\n const articleSite = (() => { try { return new URL(url).hostname.split('.').slice(-2).join('.'); } catch { return ''; } })();\n let settled = false, blockedFragments = 0;\n await page.route('**/*', async (route) => {\n const req = route.request();\n let host = '';\n try { host = new URL(req.url()).hostname; } catch { return route.continue(); }\n if (BLOCK_HOSTS.test(host)) return route.abort();\n if (settled && ['fetch', 'xhr'].includes(req.resourceType()) && host.split('.').slice(-2).join('.') === articleSite) {\n const resp = await route.fetch().catch(() => null);\n if (!resp) return route.abort();\n if (/text\\/html/i.test(resp.headers()['content-type'] || '')) { blockedFragments++; return route.abort('blockedbyclient'); }\n return route.fulfill({ response: resp });\n }\n return route.continue();\n });\n const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });\n await page.waitForTimeout(3500);\n settled = true;\n\n // A bot-protection interstitial is not the article. Stop honestly; never try to get around it.\n const probe = await page.evaluate(pageProbe);\n const block = detectBlock({ status: response ? response.status() : 0, ...probe });\n if (block.blocked) {\n console.error(`BLOCKED: ${url} served ${block.reason} to the automated browser instead of the article.`);\n console.error('Do not try to get around it. Ask the user for their own PDF or screenshot of the article and use that instead.');\n await browser.close();\n process.exit(EXIT_BLOCKED);\n }\n\n const meta = await page.evaluate(readPageMeta);\n const pinned = await page.evaluate(pinRoot, { root, headlines: meta.headline_candidates });\n await page.evaluate(loadArticleImages);\n await page.waitForTimeout(800);\n\n // --- resolve the outlet logo BEFORE surgery: every clip must carry one (it is the\n // key trust signal). Priority: explicit --logo > article masthead > HOME-PAGE masthead\n // > og:logo/favicon > text wordmark. The home-page step exists because some article\n // templates render no masthead logo at all; we navigate to the home page to grab one. ---\n let logo = { logoSrc: '', logoSvg: '', plate: null, color: '', animated: false }, logoFrom = '';\n if (logoArg) { logo.logoSrc = logoArg; logoFrom = 'explicit (--logo)'; }\n else { logo = await page.evaluate(detectMastheadLogo); if (logo.logoSrc || logo.logoSvg) logoFrom = 'article page'; }\n if (!logo.logoSrc && !logo.logoSvg) {\n try {\n const origin = new URL(url).origin;\n const home = await context.newPage();\n await home.route('**/*', (route) => {\n try { return BLOCK_HOSTS.test(new URL(route.request().url()).hostname) ? route.abort() : route.continue(); }\n catch { return route.continue(); }\n });\n await home.goto(origin, { waitUntil: 'domcontentloaded', timeout: 60000 });\n await home.waitForTimeout(2000);\n logo = await home.evaluate(detectMastheadLogo);\n if (logo.logoSrc || logo.logoSvg) logoFrom = 'home page';\n await home.close();\n } catch { /* home-page fetch is best-effort */ }\n }\n if (!logo.logoSrc && !logo.logoSvg) { logo.logoSrc = await page.evaluate(detectLogoFallback); if (logo.logoSrc) logoFrom = 'og:logo/favicon fallback'; }\n if (!logo.logoSrc && !logo.logoSvg) logoFrom = 'TEXT WORDMARK — no logo found';\n\n const outletName = meta.outlet.value || probe.title.replace(/.*[-|\\u2013\\u2014]\\s*/, '').trim() || new URL(url).hostname.replace(/^www\\./, '');\n const { stripped, sectionFound } = await page.evaluate(surgery, { section, keep, drop, outletName, ...logo });\n const pinnedHeadline = await page.evaluate(() => {\n const h = document.querySelector('[data-pc-h1]') || document.querySelector('[data-pc-root] h1');\n return h ? h.textContent.replace(/\\s+/g, ' ').trim() : null;\n });\n if (pinnedHeadline) meta.visible_headline = pinnedHeadline;\n if (section && !sectionFound) warn('section_not_found', `No heading starting with \"${section}\" was found; the whole article was kept.`);\n\n // Force a white page background so Chromium doesn't paint the below-content area of the final\n // A4 page with a site's off-white/grey body color (a common trailing-band artifact). Injected\n // last so it wins the cascade even against the site's own !important html/body background.\n await page.addStyleTag({ content: 'html,body{background:#fff !important}@media print{html,body{background:#fff !important}}' });\n await page.evaluate(loadArticleImages);\n await page.evaluate(alignStamp);\n await page.evaluate(freezePage);\n\n // An animated logo has no single right frame. Try its first and last frames and keep the one\n // with more ink (the full wordmark rather than a collapsed monogram).\n let logoFrame = null;\n if (logo.logoSvg && await page.evaluate(() => !!document.querySelector('.pc-logo svg') && (document.querySelector('.pc-logo svg').getAnimations({ subtree: true }).length > 0 || !!document.querySelector('.pc-logo svg animate, .pc-logo svg animateTransform, .pc-logo svg set')))) {\n const ink = {};\n for (const which of ['start', 'end']) {\n await page.evaluate(setLogoFrame, which);\n const shot = await page.locator('.pc-logo').first().screenshot({ omitBackground: true, animations: 'allow', style: 'html,body,.pc-header,.pc-plate{background:transparent !important}' });\n ink[which] = await page.evaluate(inkWidth, shot.toString('base64'));\n }\n logoFrame = ink.start >= ink.end ? 'start' : 'end';\n await page.evaluate(setLogoFrame, logoFrame);\n console.log(`animated logo frozen on its ${logoFrame} frame (ink width start=${ink.start}px, end=${ink.end}px)`);\n }\n await page.waitForTimeout(600);\n\n if (pinned.h1Count > 1) warn('multiple_h1', `The page had ${pinned.h1Count} \u003ch1> elements; the article was pinned to \"${pinned.h1Text || 'the first one'}\".`);\n if (blockedFragments) console.log(`blocked ${blockedFragments} next-article HTML fetch(es) after load`);\n if (meta.paywalled) warn('paywall_suspected', 'The page marks itself as not free to read (isAccessibleForFree: false). Continue only if the user can read the full body through their own access; if it is truncated, stop without producing a clip.');\n if (logoFrom.startsWith('TEXT')) warn('logo_text_fallback', 'No outlet logo was found on the article or home page; the clip shows a text wordmark. Re-run with --logo \"\u003curl>\".');\n if (meta.visible_headline && meta.headline.source !== null && meta.headline.source !== 'visible') {\n const a = meta.visible_headline.toLowerCase(), b = (meta.headline.value || '').toLowerCase();\n if (a && b && a !== b && !a.includes(b) && !b.includes(a)) warn('headline_mismatch', `The page's metadata headline (\"${meta.headline.value}\") differs from the headline shown on the page (\"${meta.visible_headline}\"). The visible one is used.`);\n }\n // The headline shown in the capture is the one to display; metadata is the cross-check.\n if (meta.visible_headline) meta.headline = { value: meta.visible_headline, source: 'visible' };\n\n // ---- animation assets --------------------------------------------------------------\n let sidecar = null;\n if (assetsDir) {\n mkdirSync(assetsDir, { recursive: true });\n const ambiguous = terms.filter(isAmbiguousTerm);\n // measure LAST: wait for the layout to settle, locate mentions and lay out the captures\n // immediately before the shots, then re-measure. One automatic retry if anything moved.\n const captureOnce = async () => {\n await page.evaluate(waitForStableLayout);\n const hits = terms.length ? await page.evaluate(locateMentions, { terms, ambiguous }) : [];\n const ranked = rankMentions(hits);\n const chosen = ranked[pick - 1] || null;\n const layout = await page.evaluate(measureLayout);\n const plateRect = layout.plate || layout.logo;\n const content = layout.content || { x0: layout.root.x, x1: layout.root.x + layout.root.w };\n const colX0 = Math.floor(Math.min(content.x0 - 24, plateRect ? plateRect.x - 16 : Infinity));\n const colX1 = Math.ceil(Math.max(content.x1 + 24, plateRect ? plateRect.x + plateRect.w + 16 : 0));\n const column = { x: Math.max(0, colX0), w: Math.min(1180, colX1) - Math.max(0, colX0) };\n const pageTop = Math.floor(Math.min(layout.header ? layout.header.y : 0, layout.root.y));\n const rootBottom = layout.root.y + layout.root.h;\n const maxCss = Math.floor(MAX_TILE_DEVICE_PX / scale);\n let topBottom = layout.hero ? layout.hero.y + layout.hero.h + 32 : (layout.h1 ? layout.h1.y + layout.h1.h + 700 : pageTop + 1400);\n topBottom = Math.min(topBottom, rootBottom, pageTop + maxCss);\n const assets = [];\n const shoot = async (file, role, rect, extra = {}) => {\n const clip = { x: column.x, y: Math.max(0, Math.floor(rect.y)), width: column.w, height: Math.ceil(rect.h) };\n const buffer = await page.screenshot({ path: join(assetsDir, file), fullPage: true, clip, animations: 'allow', caret: 'hide' });\n const size = pngSize(buffer);\n const asset = { role, file, width: size.width, height: size.height, scale, rect: { x: clip.x, y: clip.y, w: clip.width, h: clip.height }, ...extra };\n assets.push(asset);\n return asset;\n };\n await shoot('top.png', 'top', { y: pageTop, h: topBottom - pageTop });\n const stripBottom = Math.min(rootBottom, Math.max(topBottom, chosen ? Math.max(chosen.rects.paragraph.y + chosen.rects.paragraph.h, chosen.context.bottom) + 900 : topBottom));\n const tiles = planStrips(pageTop, stripBottom, scale);\n for (let i = 0; i \u003c tiles.length; i++) await shoot(`strip-${i + 1}.png`, 'strip', tiles[i], { index: i });\n if (chosen) {\n const y0 = Math.max(pageTop, Math.min(chosen.context.top, chosen.rects.paragraph.y - 40) - 24);\n const y1 = Math.min(rootBottom, Math.max(chosen.context.bottom, chosen.rects.paragraph.y + chosen.rects.paragraph.h + 40) + 24);\n await shoot('mention-1.png', 'mention', { y: y0, h: Math.min(y1 - y0, maxCss) }, { mention: chosen.index });\n }\n // the mark itself on a transparent background (the reel prefers the crop from top.png)\n let mastheadAsset = null;\n if (layout.logo && (logo.logoSrc || logo.logoSvg)) {\n const buffer = await page.locator('.pc-logo').first().screenshot({ path: join(assetsDir, 'masthead.png'), omitBackground: true, animations: 'allow', style: 'html,body,.pc-header,.pc-plate{background:transparent !important}' });\n const size = pngSize(buffer);\n mastheadAsset = { role: 'masthead', file: 'masthead.png', width: size.width, height: size.height, scale, rect: roundRect(layout.logo) };\n assets.push(mastheadAsset);\n }\n // the captures must match the measurements: re-measure and flag any shift\n const after = await page.evaluate(remeasure, chosen ? chosen.index : -1);\n const shifted = Math.abs(after.root.y - layout.root.y) > 1 || Math.abs(after.root.h - layout.root.h) > 1\n || (chosen && after.term && (Math.abs(after.term.y - chosen.rects.term[0].y) > 1 || Math.abs(after.term.x - chosen.rects.term[0].x) > 1));\n const shiftDetail = !shifted ? null : `The page layout moved while the captures were taken (article top ${layout.root.y}→${after.root.y}, height ${Math.round(layout.root.h)}→${Math.round(after.root.h)}${chosen && after.term ? `, mention ${Math.round(chosen.rects.term[0].x)},${Math.round(chosen.rects.term[0].y)}→${Math.round(after.term.x)},${Math.round(after.term.y)}` : ''}); re-run the clip before using the rectangles.`;\n return { ranked, chosen, layout, assets, mastheadAsset, plateRect, shiftDetail };\n };\n let capture = await captureOnce();\n if (capture.shiftDetail) {\n console.log('layout moved during capture; measuring and capturing again');\n for (const a of capture.assets) { try { unlinkSync(join(assetsDir, a.file)); } catch {} }\n capture = await captureOnce();\n }\n const { ranked, chosen, layout, assets, mastheadAsset, plateRect } = capture;\n if (capture.shiftDetail) warn('layout_shifted', capture.shiftDetail);\n if (terms.length && !ranked.length) warn('no_mention_found', `None of ${terms.map((t) => `\"${t}\"`).join(', ')} appears in the article body. Do not clip it for the client unless you can confirm the mention yourself.`);\n else if (terms.length && !chosen) warn('pick_out_of_range', `--pick ${pick} is beyond the ${ranked.length} ranked mention(s); no mention was captured.`);\n\n const pageAssets = assets.filter((a) => a.role !== 'masthead');\n const mentions = ranked.slice(0, 20).map((hit, i) => {\n const lines = { term: mergeLineRects(hit.rects.term), sentence: mergeLineRects(hit.rects.sentence), clause: mergeLineRects(hit.rects.clause) };\n return {\n rank: i + 1,\n chosen: hit === chosen,\n term: hit.term,\n text: hit.text,\n sentence: hit.sentence,\n clause: hit.clause,\n block: hit.block.toLowerCase(),\n in_link: hit.inLink,\n score: hit.score,\n reasons: hit.reasons,\n rects: { ...lines, paragraph: roundRect(hit.rects.paragraph) },\n asset_rects: pageAssets\n .map((a) => ({ file: a.file, term: toAssetRects(lines.term, a), sentence: toAssetRects(lines.sentence, a), clause: toAssetRects(lines.clause, a) }))\n .filter((r) => r.term.length || r.sentence.length || r.clause.length),\n };\n });\n sidecar = {\n version: SIDECAR_VERSION,\n tool: TOOL_VERSION,\n source_url: url,\n final_url: page.url(),\n canonical_url: meta.canonical_url,\n captured_at: new Date().toISOString(),\n viewport: contextOptions.viewport,\n scale,\n scope: section && sectionFound ? 'section' : 'whole',\n section: section && sectionFound ? section : null,\n mention_terms: terms,\n page: { outlet: meta.outlet, headline: meta.headline, byline: meta.byline, published_at: meta.published_at },\n logo: {\n resolved_from: logoFrom.startsWith('TEXT') ? 'text fallback' : logoFrom,\n kind: logo.logoSvg ? 'svg' : logo.logoSrc ? 'image' : 'text',\n frame: logoFrame,\n plate_color: logo.plate || null,\n file: mastheadAsset ? mastheadAsset.file : null,\n rect: layout.logo ? roundRect(layout.logo) : null,\n plate_rect: plateRect ? roundRect(plateRect) : null,\n },\n root: roundRect(layout.root),\n headline_rect: layout.h1 ? roundRect(layout.h1) : null,\n assets,\n mentions,\n warnings,\n };\n }\n\n // ---- preview and PDF ---------------------------------------------------------------\n if (preview) {\n const docHeight = await page.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body.scrollHeight));\n const mode = pickPreviewMode(docHeight, contextOptions.deviceScaleFactor);\n if (mode.warning) warn(mode.warning.code, mode.warning.detail);\n await page.screenshot({ path: preview, fullPage: true, scale: mode.scale, animations: 'allow', caret: 'hide',\n ...(mode.clipHeight ? { clip: { x: 0, y: 0, width: contextOptions.viewport.width, height: mode.clipHeight } } : {}) });\n }\n await page.pdf({ path: out, format: 'A4', printBackground: true, margin: { top: '10mm', bottom: '12mm', left: '8mm', right: '8mm' } });\n if (sidecar) {\n sidecar.warnings = warnings;\n writeFileSync(join(assetsDir, 'clip.json'), JSON.stringify(sidecar, null, 2) + '\\n');\n }\n console.log('clip written:', out, '| logo:', logoFrom);\n if (stripped && stripped.length) console.log('swept ' + stripped.length + ' empty placeholder(s):\\n - ' + stripped.join('\\n - '));\n if (sidecar) {\n const m = sidecar.mentions.find((x) => x.chosen);\n console.log(`assets written: ${assetsDir} (${sidecar.assets.length} files + clip.json)`);\n if (m) console.log(`mention (rank ${m.rank}, score ${m.score}): \"${m.sentence}\"`);\n }\n } finally {\n await browser.close();\n }\n}\n\nif (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {\n main().catch((e) => { console.error('ERR', e.message); process.exit(1); });\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":71580,"content_sha256":"c45bca75fb13e28187c44fe9f75402ca5a648b53e0871f7e83bce3637c9f9bd0"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Press Clip","type":"text"}]},{"type":"paragraph","content":[{"text":"You are ","type":"text"},{"text":"press-clip","type":"text","marks":[{"type":"strong"}]},{"text":", the Newsjack skill that turns a live article into the artifact a PR agency hands a client: proof their coverage ran, in a form the client trusts on sight.","type":"text"}]},{"type":"paragraph","content":[{"text":"Understand this first, because it's the mistake that ruins clips: ","type":"text"},{"text":"a press clip must look like the publication it came from.","type":"text","marks":[{"type":"strong"}]},{"text":" The outlet's logo, masthead, real fonts, the article's photos, the familiar layout — those are not decoration, they are the ","type":"text"},{"text":"trust signals","type":"text","marks":[{"type":"em"}]},{"text":" that tell a client \"yes, this really ran in this outlet.\" A clip rebuilt as plain text reads like a memo and convinces no one. So you do ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" rebuild the page. You take the ","type":"text"},{"text":"real, rendered page","type":"text","marks":[{"type":"strong"}]},{"text":" and operate on it: isolate the article, strip the ads and clutter, stamp the outlet's logo on top — and leave every trust signal intact.","type":"text"}]},{"type":"paragraph","content":[{"text":"A clip is also ","type":"text"},{"text":"evidence","type":"text","marks":[{"type":"strong"}]},{"text":". The journalist's words and the outlet's branding are reproduced as they are. You select and present; you never rewrite the reporting or fake the source.","type":"text"}]},{"type":"paragraph","content":[{"text":"Only clip a page the user can already read in full, either publicly or through their own logged-in access. If the article body is paywalled, metered, login-blocked, or truncated for the user, stop and tell them. Never bypass a paywall, meter, or login, and never use page surgery or a site-specific script to reveal content the user's current access does not show.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"The one rule that shapes everything","type":"text"}]},{"type":"paragraph","content":[{"text":"Every publication's HTML is different, so there is no universal \"remove the junk\" selector list — and the bundled script deliberately contains no site-specific logic.","type":"text","marks":[{"type":"strong"}]},{"text":" Broad keyword selectors (","type":"text"},{"text":"[class*=\"sidebar\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"[class*=\"social\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":") look tempting but betray you: real layout wrappers reuse those words (a responsive grid classed ","type":"text"},{"text":"layout--has-sidebar","type":"text","marks":[{"type":"code_inline"}]},{"text":" actually ","type":"text"},{"text":"holds the article body","type":"text","marks":[{"type":"em"}]},{"text":"), so a blind rule deletes the story on some templates. The robust division of labor is:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Structure is generic.","type":"text","marks":[{"type":"strong"}]},{"text":" The script isolates the article by its position in the page tree — it keeps the article and its ancestor chain and drops everything that is a ","type":"text"},{"text":"sibling","type":"text","marks":[{"type":"em"}]},{"text":" of that chain (site header, nav, sidebars, footer, recirculation rails). This needs no class names and works across templates.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Per-publisher junk is yours to find at runtime.","type":"text","marks":[{"type":"strong"}]},{"text":" Ads, sponsored modules, newsletter sign-ups, \"around the web\" / Taboola / Zergnet rails, comment embeds, in-article video players — these live ","type":"text"},{"text":"inside","type":"text","marks":[{"type":"em"}]},{"text":" the article on many sites and differ per publisher. You inspect the specific page, identify those blocks, and remove them with ","type":"text"},{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" selectors, or by writing a small tailored Playwright script. Then you ","type":"text"},{"text":"look at the preview","type":"text","marks":[{"type":"strong"}]},{"text":" and iterate.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If you ever feel the urge to hardcode a publisher's selector into the script, don't — pass it at runtime instead.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"What you need","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The article URL.","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The client","type":"text","marks":[{"type":"strong"}]},{"text":" — the brand, product, person, or company the clip is ","type":"text"},{"text":"for","type":"text","marks":[{"type":"em"}]},{"text":". In a roundup you'll narrow to its section.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Optional: whole article vs the client's section (see Scope), and where to save (defaults to ","type":"text"},{"text":"press-clips/","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Setup","type":"text"}]},{"type":"paragraph","content":[{"text":"The clipper drives a real Chromium-based browser (Chrome or Edge — most machines have one) and needs one npm package. From the folder you'll run clips in:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"npm i playwright-core","type":"text"}]},{"type":"paragraph","content":[{"text":"If the browser isn't at the default path, pass ","type":"text"},{"text":"--chrome \"/path/to/Chrome\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" or set ","type":"text"},{"text":"PRESS_CLIP_CHROME","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"The workflow","type":"text"}]},{"type":"paragraph","content":[{"text":"Before running the clipper, open the URL with the user's current permitted access and confirm the complete article body is visible. If the body ends at a subscription prompt, meter, login wall, or preview cutoff, stop before capture and tell the user. A ","type":"text"},{"text":"paywall_suspected","type":"text","marks":[{"type":"code_inline"}]},{"text":" warning is a cue to confirm access, not permission to work around it.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1. Run the baseline clip","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node clip.mjs --url \"\u003cURL>\" \\\n --out \"press-clips/\u003coutlet>-\u003cslug>.pdf\" --preview \"press-clips/\u003coutlet>-\u003cslug>.png\"","type":"text"}]},{"type":"paragraph","content":[{"text":"For a ","type":"text"},{"text":"roundup","type":"text","marks":[{"type":"strong"}]},{"text":" where the client is one entry among many, add ","type":"text"},{"text":"--section \"\u003cClient Name>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" to keep only their part.","type":"text"}]},{"type":"paragraph","content":[{"text":"The script will: block ad/recirc/comment/video networks at the request level (this alone removes most lazy-loaded junk generically), load the page, ","type":"text"},{"text":"pick the article container structurally","type":"text","marks":[{"type":"strong"}]},{"text":" before anything scrolls (the tightest element that holds the ","type":"text"},{"text":"\u003ch1>","type":"text","marks":[{"type":"code_inline"}]},{"text":" matching the page's own headline and most of the page text, never one nested in ","type":"text"},{"text":"header","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"nav","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"footer","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"aside","type":"text","marks":[{"type":"code_inline"}]},{"text":"), isolate it, sweep out ","type":"text"},{"text":"empty placeholder boxes","type":"text","marks":[{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"link lists","type":"text","marks":[{"type":"strong"}]},{"text":" (a dead ad slot that still takes height; a \"most read\" rail or related-stories list that is almost all links — both logged, never deleted silently), force a ","type":"text"},{"text":"white page background","type":"text","marks":[{"type":"strong"}]},{"text":" so no off-white site color bleeds into the last page, ","type":"text"},{"text":"resolve the outlet logo","type":"text","marks":[{"type":"strong"}]},{"text":", stamp it large at the top, and write the PDF plus a preview PNG. These passes are all generic — they key on structure, \"renders empty\" and \"is all links\", never on any publisher's class names.","type":"text"}]},{"type":"paragraph","content":[{"text":"Other generic safeguards run on every page, learned from real failures:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Next-article loaders are stopped.","type":"text","marks":[{"type":"strong"}]},{"text":" Some templates append the next story as you scroll and rewrite the address bar. Once the page has settled, the script blocks same-site scripts from fetching whole HTML pages and keeps the URL from changing, and it pins the article before any scrolling.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Floating chrome is hidden.","type":"text","marks":[{"type":"strong"}]},{"text":" Fixed and sticky video players, \"keep scrolling\" bars and cookie overlays outside the article are hidden, and the scroll lock that cookie dialogs put on the page is released (it otherwise cuts the PDF to one page).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lazy images are loaded","type":"text","marks":[{"type":"strong"}]},{"text":" and decoded inside the article, and fonts are awaited, so far-down photos are not blank.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Animations are frozen.","type":"text","marks":[{"type":"strong"}]},{"text":" An animated logo has no single right frame: the script tries its first and last frames and keeps the one with more ink (the full wordmark, not a collapsed monogram).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tall pages are never repeated.","type":"text","marks":[{"type":"strong"}]},{"text":" Chrome silently repeats a screenshot's content past about 16,000 pixels. The preview drops to 1x for tall pages and stops at 16,000 px with a warning for very tall ones; the PDF always has the whole article.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Every clip must carry the outlet's real logo","type":"text","marks":[{"type":"strong"}]},{"text":" — it is the single most important trust signal. The logo is stamped exactly as the site shows it: its own colours, on a plate of the header colour it sat on (a white logo keeps its dark header behind it). It is never recoloured. The script resolves it in this order: explicit ","type":"text"},{"text":"--logo","type":"text","marks":[{"type":"code_inline"}]},{"text":" → the article page's masthead → ","type":"text"},{"text":"the outlet's home page","type":"text","marks":[{"type":"strong"}]},{"text":" (it navigates there automatically when the article template has no masthead logo) → og:logo/favicon → a text wordmark as the absolute last resort. The console line ends with ","type":"text"},{"text":"| logo: \u003csource>","type":"text","marks":[{"type":"code_inline"}]},{"text":" so you can see where it came from; a ","type":"text"},{"text":"TEXT WORDMARK — no logo found","type":"text","marks":[{"type":"code_inline"}]},{"text":" warning means ","type":"text"},{"text":"no logo was found anywhere","type":"text","marks":[{"type":"strong"}]},{"text":" and the clip is not shippable as-is — find a logo (open the outlet's home page yourself) and re-run with ","type":"text"},{"text":"--logo \"\u003curl>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"2. Review with a separate agent — required, not optional","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not trust your own \"looks fine.\" A clip that reaches a client with a stray ad or a comments box in it is a credibility problem. ","type":"text"},{"text":"Spawn a separate reviewer agent","type":"text","marks":[{"type":"strong"}]},{"text":" whose only job is to find leftover junk in the rendered clip ","type":"text"},{"text":"and to confirm the outlet logo is present","type":"text","marks":[{"type":"em"}]},{"text":". Give it: the ","type":"text"},{"text":"PDF","type":"text","marks":[{"type":"strong"}]},{"text":" path, the preview PNG path, the source URL, the client name, and the definition of a clean clip (the outlet logo large at the top, headline, byline, the article's own photos, body text — nothing else).","type":"text"}]},{"type":"paragraph","content":[{"text":"Review the rendered PDF, not just the preview PNG.","type":"text","marks":[{"type":"strong"}]},{"text":" Some artifacts only exist after pagination — a grey/off-white band at the foot of the last page, content clipped at a page break — and the full-page web screenshot looks pixel-clean even when the PDF doesn't. The reviewer must ","type":"text"},{"text":"rasterize the final PDF","type":"text","marks":[{"type":"strong"}]},{"text":" (at least its last page and any page breaks) and inspect ","type":"text"},{"text":"those","type":"text","marks":[{"type":"em"}]},{"text":" images, not only the web preview. Otherwise trailing-band and page-break problems sail through review.","type":"text"}]},{"type":"paragraph","content":[{"text":"The logo is a required check, not a nice-to-have.","type":"text","marks":[{"type":"strong"}]},{"text":" The reviewer must confirm the top of the clip shows the outlet's ","type":"text"},{"text":"real logo","type":"text","marks":[{"type":"strong"}]},{"text":" (its image or SVG wordmark), not a plain text rendering of the outlet name. A text-only logo means resolution fell all the way through — treat the clip as ","type":"text"},{"text":"not shippable","type":"text","marks":[{"type":"strong"}]},{"text":". When the logo is missing or is only text:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Open the outlet's home page","type":"text","marks":[{"type":"strong"}]},{"text":" (Playwright/browser tool) and find the masthead logo — the ","type":"text"},{"text":"\u003cimg>","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"\u003csvg>","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the site header that links to home. Grab its absolute image URL.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-run the clip with ","type":"text"},{"text":"--logo \"\u003cthat url>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", which stamps it at the top, then review the new preview.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"(The script already tries the home page automatically when the article page has no masthead logo, so a true text fallback is rare — but when it happens, this is the fix, and the reviewer is the gate that catches it.)","type":"text"}]},{"type":"paragraph","content":[{"text":"Beyond the logo, the reviewer must follow two rules, both learned the hard way:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify every selector against the live DOM, never from the picture alone.","type":"text","marks":[{"type":"strong"}]},{"text":" A reviewer that eyeballs the screenshot and guesses class names produces selectors that match nothing. The reviewer must open the page (Playwright/browser tool), confirm each proposed selector exists, count its matches, and confirm it does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" contain the article body. Return only verified selectors.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Distinguish editorial from junk.","type":"text","marks":[{"type":"strong"}]},{"text":" A first-party photo served from the outlet's own domain, sitting in a ","type":"text"},{"text":"\u003cfigure>","type":"text","marks":[{"type":"code_inline"}]},{"text":" inside the body, is article content — ","type":"text"},{"text":"keep it","type":"text","marks":[{"type":"strong"}]},{"text":", even if it looks like a brand image (e.g. a fashion photo). Only flag true ads/recirc/sponsored/comments. When genuinely unsure whether an image is a native ad or an editorial photo, ","type":"text"},{"text":"surface it to the user rather than deleting it","type":"text","marks":[{"type":"strong"}]},{"text":" — removing a real photo corrupts the clip.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Have the reviewer return a verdict (","type":"text"},{"text":"clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"has_junk","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"no_logo","type":"text","marks":[{"type":"code_inline"}]},{"text":"), a verified body-safe ","type":"text"},{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" string, and — if the logo is missing — the home-page logo URL to pass via ","type":"text"},{"text":"--logo","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3. Apply the drops, re-render, and re-review until clean","type":"text"}]},{"type":"paragraph","content":[{"text":"Pass the reviewer's ","type":"text"},{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" selectors (and ","type":"text"},{"text":"--logo","type":"text","marks":[{"type":"code_inline"}]},{"text":" if it returned ","type":"text"},{"text":"no_logo","type":"text","marks":[{"type":"code_inline"}]},{"text":"), re-render, and ","type":"text"},{"text":"send the new preview back to the reviewer","type":"text","marks":[{"type":"strong"}]},{"text":". Repeat until the verdict is ","type":"text"},{"text":"clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" — which requires the logo present — or only user-judgment items (like an ambiguous image) remain. Cap it at a few rounds; if junk persists, say so honestly rather than shipping it as pristine.","type":"text"}]},{"type":"paragraph","content":[{"text":"When you need to find a selector yourself, inspect the offending block's ","type":"text"},{"text":"class","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"id","type":"text","marks":[{"type":"code_inline"}]},{"text":" directly. Useful moves:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Find the article container the script will pick: the tightest of ","type":"text"},{"text":"\u003carticle>","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"[class*=\"article-content\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"[class*=\"entry-content\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":" / WordPress ","type":"text"},{"text":".post","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"main","type":"text","marks":[{"type":"code_inline"}]},{"text":" that holds the ","type":"text"},{"text":"\u003ch1>","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ≥60% of the page text and isn't inside site chrome. If it still picks wrong (a recirc card or a too-loose ","type":"text"},{"text":"\u003cmain>","type":"text","marks":[{"type":"code_inline"}]},{"text":"), don't fork — pass ","type":"text"},{"text":"--root \"\u003cselector>\"","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" to force the right container.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For each leftover widget, grab the ","type":"text"},{"text":"narrowest stable class","type":"text","marks":[{"type":"strong"}]},{"text":" on its wrapper (e.g. ","type":"text"},{"text":".zergnet-widget","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".nyp-video-player","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"aside.single__inline-module","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Prefer a class that names the widget, not a layout grid.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never","type":"text","marks":[{"type":"strong"}]},{"text":" drop a class that also wraps the body. If the body sits in ","type":"text"},{"text":"layout__item--main","type":"text","marks":[{"type":"code_inline"}]},{"text":", target the ","type":"text"},{"text":"other","type":"text","marks":[{"type":"em"}]},{"text":" columns (","type":"text"},{"text":".layout__item:not(.layout__item--main)","type":"text","marks":[{"type":"code_inline"}]},{"text":"), not the shared grid.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Then re-run with, for example:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node clip.mjs --url \"\u003cURL>\" \\\n --drop \".zergnet-widget, .nyp-video-player, aside.single__inline-module, [class*=taboola i]\" \\\n --keep \".gallery, figure.hero\" \\\n --out \"press-clips/\u003coutlet>-\u003cslug>.pdf\" --preview \"press-clips/\u003coutlet>-\u003cslug>.png\"","type":"text"}]},{"type":"paragraph","content":[{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" removes extra selectors; ","type":"text"},{"text":"--keep","type":"text","marks":[{"type":"code_inline"}]},{"text":" protects anything the isolation or a drop would otherwise take (a gallery, a pull-quote, a hero image). Repeat until the preview is clean.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"4. When the page fights you, tailor at runtime — then, only if needed, write a script","type":"text"}]},{"type":"paragraph","content":[{"text":"Reach for the runtime flags first; they cover most fights without forking. ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" forces the article container; ","type":"text"},{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" removes site-specific junk; ","type":"text"},{"text":"--keep","type":"text","marks":[{"type":"code_inline"}]},{"text":" protects content a drop would catch. Two failure patterns from real runs, both now handled generically by the script but worth recognizing:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A recirc widget steals the root","type":"text","marks":[{"type":"strong"}]},{"text":" on templates with no ","type":"text"},{"text":"\u003carticle>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — a tiny \"more from us\" card classed ","type":"text"},{"text":"post-content","type":"text","marks":[{"type":"code_inline"}]},{"text":" outscores the real body under naive first-match. The script now picks the ","type":"text"},{"text":"tightest container holding the ","type":"text","marks":[{"type":"em"}]},{"text":"\u003ch1>","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" and most text, excluding site chrome","type":"text","marks":[{"type":"em"}]},{"text":", which fixes it automatically; if a stubborn template still misfires, ","type":"text"},{"text":"--root","type":"text","marks":[{"type":"code_inline"}]},{"text":" is the one-flag cure.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A blocked embed or ad leaves an empty box","type":"text","marks":[{"type":"strong"}]},{"text":" — request-level blocking kills the ad/tweet network but leaves a sized, contentless ","type":"text"},{"text":"\u003cdiv>","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"\u003cblockquote>","type":"text","marks":[{"type":"code_inline"}]},{"text":" behind. The script's empty-placeholder sweep removes these (and logs them); you rarely need a ","type":"text"},{"text":"--drop","type":"text","marks":[{"type":"code_inline"}]},{"text":" for dead space anymore.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Some pages still need more — a lazy body the user can already read in full, a section boundary the heading walk can't infer, an SVG logo built from sprite references, content injected late by JavaScript. When the flags genuinely can't reach it, ","type":"text"},{"text":"write a small site-specific Playwright script","type":"text","marks":[{"type":"strong"}]},{"text":" for that page (or use a browser/computer-use tool to drive it), reusing the same shape as ","type":"text"},{"text":"clip.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" — goto, wait, surgery, ","type":"text"},{"text":"page.pdf(...)","type":"text","marks":[{"type":"code_inline"}]},{"text":". The bundled script is a starting scaffold, not a limit. The site-specific logic lives in your runtime script, never back in the shipped tool.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Scope: whole article vs the client's section","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Scope","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use it when","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Whole article","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The piece is about the client, or short","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"(no extra flag)","type":"text","marks":[{"type":"em"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Client's section only","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A roundup where the client is one of many entries","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--section \"\u003cHeading>\"","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"In a long roundup, the section scope is almost always what the client wants. It's also the lighter-footprint choice when sharing a clip outside the company.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Animation assets (for coverage-reel)","type":"text"}]},{"type":"paragraph","content":[{"text":"When a clip will be animated, add three flags:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flag","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What it does","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--mention \"\u003cterm>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The client's names to find: company, products, spellings, spokespeople. Repeat it or use a comma list. Matching is case-sensitive and the longest name wins. A single capitalised word that is also a common word (for example \"Nothing\" or \"Apple\") counts only when it is not the first word of a sentence or part of a title-case heading.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--assets \u003cdir>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Writes the captures and a ","type":"text"},{"text":"clip.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" sidecar into this folder.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--scale 2","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"3","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pixel density of the captures (default 2; zooming in needs at least 2). Use 3 for coverage-reel, whose phone formats zoom in close on the sentence.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--pick \u003cn>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Capture the n-th ranked mention instead of the best one, when the reviewer prefers another sentence.","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The folder gets ","type":"text"},{"text":"top.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the stamped logo, headline, byline and lead photo), ","type":"text"},{"text":"strip-1.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" and onward (the article from the top through the chosen mention, in tiles well under Chrome's size limit), ","type":"text"},{"text":"mention-1.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the mention with a paragraph of context) and ","type":"text"},{"text":"masthead.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the logo on a transparent background). Body sentences are ranked above headlines, captions, links, related-story lists and navigation; every hit is listed so a reviewer can pick another.","type":"text"}]},{"type":"paragraph","content":[{"text":"clip.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a machine handoff (version 1). Its main fields:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"version\": 1,\n \"source_url\": \"https://example.com/article\",\n \"final_url\": \"https://example.com/article\",\n \"captured_at\": \"2026-10-10T14:00:00.000Z\",\n \"tool\": \"press-clip/clip.mjs 2\",\n \"scale\": 2,\n \"scope\": \"whole\",\n \"page\": {\n \"outlet\": { \"value\": \"Example News\", \"source\": \"json-ld\" },\n \"headline\": { \"value\": \"Headline as shown on the page\", \"source\": \"visible\" },\n \"byline\": { \"value\": \"Jo Writer\", \"source\": \"json-ld\" },\n \"published_at\": { \"value\": \"2026-05-18T06:00:00Z\", \"source\": \"og\" }\n },\n \"logo\": { \"resolved_from\": \"article page\", \"kind\": \"svg\", \"frame\": \"start\", \"plate_color\": \"rgb(5, 41, 98)\", \"file\": \"masthead.png\", \"rect\": {}, \"plate_rect\": {} },\n \"assets\": [{ \"role\": \"top\", \"file\": \"top.png\", \"width\": 1656, \"height\": 1990, \"scale\": 2, \"rect\": { \"x\": 16, \"y\": 0, \"w\": 828, \"h\": 995 } }],\n \"mentions\": [{\n \"rank\": 1, \"chosen\": true, \"term\": \"Example Co\", \"sentence\": \"…\", \"clause\": \"…\", \"in_link\": false, \"score\": 7,\n \"rects\": { \"term\": [], \"sentence\": [], \"clause\": [], \"paragraph\": {} },\n \"asset_rects\": [{ \"file\": \"strip-1.png\", \"term\": [], \"sentence\": [], \"clause\": [] }]\n }],\n \"warnings\": [{ \"code\": \"paywall_suspected\", \"detail\": \"…\" }]\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Page details are read, never inferred: each has the ","type":"text"},{"text":"value","type":"text","marks":[{"type":"code_inline"}]},{"text":" the page gives (or ","type":"text"},{"text":"null","type":"text","marks":[{"type":"code_inline"}]},{"text":") and its ","type":"text"},{"text":"source","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"json-ld","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"og","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"meta","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"visible","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Author emails are dropped. The headline is the one shown on the page. The publish date is copied as the page states it and is not checked; confirm it with the user before using it. Rectangles are in page pixels (","type":"text"},{"text":"rects","type":"text","marks":[{"type":"code_inline"}]},{"text":") and in each image's own pixels (","type":"text"},{"text":"asset_rects","type":"text","marks":[{"type":"code_inline"}]},{"text":"); a sentence that wraps has one rectangle per line. Warning codes: ","type":"text"},{"text":"paywall_suspected","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"no_mention_found","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"logo_text_fallback","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"layout_shifted","type":"text","marks":[{"type":"code_inline"}]},{"text":" (re-run), ","type":"text"},{"text":"headline_mismatch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"multiple_h1","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"section_not_found","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pick_out_of_range","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"preview_downscaled","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"preview_truncated","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"If the page is blocked","type":"text"}]},{"type":"paragraph","content":[{"text":"Some sites answer automated browsers with a bot check (\"Just a moment…\", an empty 403 or 503 page). The script detects this, writes nothing and exits with code 3. ","type":"text"},{"text":"Do not try to get around it","type":"text","marks":[{"type":"strong"}]},{"text":" — no stealth tricks, no faked browsers, no archive mirrors. Ask the user for their own PDF or screenshot of the article and use that instead.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"After you render","type":"text"}]},{"type":"paragraph","content":[{"text":"Tell the user, in plain language: the outlet, headline, and publish date; where the client appears; the scope used; anything you had to tailor or that's still imperfect (a stubborn ad, a logo that fell back to a wordmark); and the saved file path. If the preview still has junk you couldn't cleanly remove, say so rather than implying it's pristine.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Honesty and rights","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never fabricate","type":"text","marks":[{"type":"strong"}]},{"text":" a date, byline, quote, headline, reach figure, or any wording. The clip is the real page; don't add to it. Missing is fine and honest — say so.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Don't alter the reporting or the branding.","type":"text","marks":[{"type":"strong"}]},{"text":" You isolate and de-clutter; you do not change the journalist's words, swap the outlet's identity, or stage a mention that isn't there.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the client isn't actually in the article, stop and say so.","type":"text","marks":[{"type":"strong"}]},{"text":" Don't stretch an adjacent reference into a clip.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Rights awareness.","type":"text","marks":[{"type":"strong"}]},{"text":" Clips are normal for internal records and client reporting, but reproducing a full article to share widely has copyright limits. For external sharing, prefer the ","type":"text"},{"text":"section","type":"text","marks":[{"type":"strong"}]},{"text":" scope, and always keep the outlet's name, logo, and the live link so credit and source stay intact.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Follow ","type":"text"},{"text":"skills/ETHICS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"If the script can't run","type":"text"}]},{"type":"paragraph","content":[{"text":"No Chromium-based browser or Node available? Fall back honestly:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Open the article and use the browser's own ","type":"text"},{"text":"Print → Save as PDF","type":"text","marks":[{"type":"strong"}]},{"text":" with a reader/print setting that drops ads. It won't isolate one section, but it preserves the outlet's look.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"As a last resort, capture a ","type":"text"},{"text":"full-page screenshot","type":"text","marks":[{"type":"strong"}]},{"text":" of the client's section so the visual proof and branding survive, and tell the user it's a screenshot, not a print.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Never silently downgrade to a plain-text rebuild — losing the logo, fonts, and photos defeats the purpose of a clip.","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"press-clip","author":"@skillopedia","source":{"stars":1668,"repo_name":"newsjack","origin_url":"https://github.com/elvisun/newsjack/blob/HEAD/skills/press-clip/SKILL.md","repo_owner":"elvisun","body_sha256":"414893495bf6111de1d39330e3fae554296dd8eb042816c7bb46dfe377825314","cluster_key":"a337d64e2943046e847e9023e29a4ae66ec4c7f60f56feef1b410f2663dac5e1","clean_bundle":{"format":"clean-skill-bundle-v1","source":"elvisun/newsjack/skills/press-clip/SKILL.md","text_bytes":71580,"source_commit":"47d0fb2f7fa6296aa6baa207c21ffce6defcf78e","skills_sh_rank":16106,"attachment_count":1,"text_attachments":1,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/press-clip/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"media-content","category_label":"Media"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"media-content","metadata":{"category":"Act"},"import_tag":"skills-sh-top20k-2026-11","description":"Turn a live article URL into a press clip that looks like the real coverage — the publication's own logo, fonts, photos and layout kept intact, the ads and clutter removed, and (for a roundup) just the client's section. Renders to PDF. You inspect each site and tailor the removal; the bundled script carries no site-specific logic.","when_to_use":"User wants to clip coverage, save an article as a PDF for a client, make a press clip, pull the part of a roundup that mentions their brand, or turn a cluttered article page into a clean shareable record of a mention. Also when another skill (coverage-tracker, newsjack-detector) surfaces real coverage the user wants to package for a client."}},"renderedAt":1793671606870}

Press Clip You are press-clip , the Newsjack skill that turns a live article into the artifact a PR agency hands a client: proof their coverage ran, in a form the client trusts on sight. Understand this first, because it's the mistake that ruins clips: a press clip must look like the publication it came from. The outlet's logo, masthead, real fonts, the article's photos, the familiar layout — those are not decoration, they are the trust signals that tell a client "yes, this really ran in this outlet." A clip rebuilt as plain text reads like a memo and convinces no one. So you do not rebuild t…