and kept answering the
- // placement beside the cover with a card twice the cover's height. Pointing
- // it at a responsive Display unit is what actually binds the shape — at the
- // cost of blending its reporting with the rail unit it borrows.
- // TODO(chris): create a dedicated read_s03 Display unit and swap the id back
- // to get per-placement RPM.
- [ARBITRAGE_SLOT.inlineMpu1]: { id: '6921226982', type: 'display' },
- // TODO(chris): create the three new rail units (suggested names
- // read_s04_rail_creator, read_s05_rail_share, read_s06_rail_highlights) as
- // Display 300x250. They stay collapsed until their ids are filled in.
- [ARBITRAGE_SLOT.railAfterCreator]: { id: '', type: 'display' },
- [ARBITRAGE_SLOT.railAfterShare]: { id: '', type: 'display' },
- [ARBITRAGE_SLOT.railAfterHighlights]: { id: '', type: 'display' },
- // TODO(chris): layoutKey from the read_s07_comment_native "Get code" snippet
- // (data-ad-layout-key). The slot stays collapsed until it is filled in.
- //
- // PRECONDITIONS on filling this in — this comment is the gate, since the
- // workflow is "ship reviewed once, switch on by editing this map":
- // 1. Ad label: DONE in code — ProgrammaticAd renders the policy-permitted
- // "Advertisements" caption above every inFeed unit, so an unlabeled
- // native between comments cannot ship by omission.
- // 2. Re-measure phone ad density on a long thread with the interval live.
- // The ~27% figure was measured with this slot inert, it is the only
- // repeating slot on the page, and Chrome's Better Ads filter applies to
- // the whole domain, direct-sold inventory included.
- [ARBITRAGE_SLOT.commentNative]: { id: '', type: 'inFeed', layoutKey: '' },
+ // The three MPU placements below share existing Display units while the
+ // dedicated ones don't exist: Google's per-unit reporting blends them, but
+ // our first-party events split by slot number, so per-placement RPM stays
+ // queryable in ClickHouse.
+ // TODO(chris): create dedicated Display units (read_s17_in_body,
+ // read_s18_above_comments) and swap the ids for clean AdSense-side rows.
+ [ARBITRAGE_SLOT.commentMpu]: { id: '6921226982', type: 'display' },
[ARBITRAGE_SLOT.railAfterSource]: { id: '5249052667', type: 'display' },
[ARBITRAGE_SLOT.railBetweenFurtherReading]: {
id: '6921226982',
type: 'display',
},
+ [ARBITRAGE_SLOT.inBodyMpu]: { id: '6921226982', type: 'display' },
+ [ARBITRAGE_SLOT.aboveCommentsMpu]: { id: '5249052667', type: 'display' },
+ // read_s10's fixed 300x600, back as the rail's closing unit. Compliant as a
+ // publisher sticky: 300px wide, desktop only, and the page's ONLY sticky —
+ // AdSense allows exactly one per viewport.
+ [ARBITRAGE_SLOT.railBottomSticky]: {
+ id: '4307400883',
+ type: 'display',
+ width: 300,
+ height: 600,
+ },
};
/**
diff --git a/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts b/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts
new file mode 100644
index 0000000000..6dfbd65ef6
--- /dev/null
+++ b/packages/shared/src/components/post/arbitrage/splitContentForAds.spec.ts
@@ -0,0 +1,70 @@
+import { splitContentForAds } from './splitContentForAds';
+
+const para = (chars: number, label: string): string =>
+ `${label.repeat(Math.ceil(chars / label.length)).slice(0, chars)}
`;
+
+describe('splitContentForAds', () => {
+ it('returns short content as a single chunk', () => {
+ const html = para(100, 'a');
+ expect(splitContentForAds(html, 300)).toEqual([html]);
+ });
+
+ it('splits only at top-level block boundaries', () => {
+ const first = para(300, 'a');
+ const second = para(300, 'b');
+ const chunks = splitContentForAds(first + second, 250);
+
+ expect(chunks).toEqual([first, second]);
+ });
+
+ it('never cuts inside a nested structure', () => {
+ const list = `- ${'x'.repeat(300)}
- ${'y'.repeat(
+ 300,
+ )}
`;
+ const after = para(300, 'z');
+ const chunks = splitContentForAds(list + after, 250);
+
+ // The list crosses the threshold internally but closes as one unit.
+ expect(chunks).toEqual([list, after]);
+ });
+
+ it('treats code blocks as unsplittable units', () => {
+ const code = `${'if (x) {\n}\n'.repeat(40)}
`;
+ const after = para(300, 'a');
+ const chunks = splitContentForAds(code + after, 250);
+
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]).toBe(code);
+ });
+
+ it('does not let void elements corrupt the depth count', () => {
+ const withImages = `${'a'.repeat(150)}
${'b'.repeat(
+ 150,
+ )}
`;
+ const after = para(300, 'c');
+
+ expect(splitContentForAds(withImages + after, 250)).toEqual([
+ withImages,
+ after,
+ ]);
+ });
+
+ it('merges a trailing sliver into the previous chunk', () => {
+ const first = para(300, 'a');
+ const sliver = para(40, 'b');
+ const chunks = splitContentForAds(first + sliver, 250);
+
+ // An ad before one stray line reads as the page ending on an ad.
+ expect(chunks).toEqual([first + sliver]);
+ });
+
+ it('keeps every byte of the input across the chunks', () => {
+ const html =
+ `${para(400, 'a')}${'q'.repeat(300)}
` +
+ `Heading
${para(400, 'b')}`;
+ const chunks = splitContentForAds(html, 250);
+
+ expect(chunks.join('')).toBe(html);
+ expect(chunks.length).toBeGreaterThan(1);
+ });
+});
diff --git a/packages/shared/src/components/post/arbitrage/splitContentForAds.ts b/packages/shared/src/components/post/arbitrage/splitContentForAds.ts
new file mode 100644
index 0000000000..0cd9f0b48c
--- /dev/null
+++ b/packages/shared/src/components/post/arbitrage/splitContentForAds.ts
@@ -0,0 +1,78 @@
+const TAG_RE = /<\/?([a-zA-Z][\w-]*)(?:[^>'"]|"[^"]*"|'[^']*')*?\/?>/g;
+
+// Elements that never take a closing tag, so an opening token must not
+// increase the nesting depth.
+const VOID_ELEMENTS = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'source',
+ 'track',
+ 'wbr',
+]);
+
+const visibleLength = (text: string): number =>
+ text
+ .replace(/&[#\w]+;/g, 'x')
+ .replace(/\s+/g, ' ')
+ .trim().length;
+
+/**
+ * Splits rendered article HTML into chunks of at least `minChars` of visible
+ * text, cutting only where a top-level block element closes — an ad between
+ * chunks can never land inside a paragraph, list, blockquote or code block.
+ * A short tail is merged into the chunk before it, so the article never ends
+ * on an ad followed by a stray line.
+ */
+export function splitContentForAds(html: string, minChars: number): string[] {
+ const chunks: string[] = [];
+ let depth = 0;
+ let chunkStart = 0;
+ let cursor = 0;
+ let visible = 0;
+
+ TAG_RE.lastIndex = 0;
+ let match = TAG_RE.exec(html);
+ while (match) {
+ const [token, rawName] = match;
+ visible += visibleLength(html.slice(cursor, match.index));
+ cursor = match.index + token.length;
+
+ const name = rawName.toLowerCase();
+ if (token.startsWith('')) {
+ depth = Math.max(0, depth - 1);
+ if (depth === 0 && visible >= minChars) {
+ chunks.push(html.slice(chunkStart, cursor));
+ chunkStart = cursor;
+ visible = 0;
+ }
+ } else if (!VOID_ELEMENTS.has(name) && !token.endsWith('/>')) {
+ depth += 1;
+ }
+
+ match = TAG_RE.exec(html);
+ }
+
+ const tail = html.slice(chunkStart);
+ if (tail.trim()) {
+ chunks.push(tail);
+ }
+
+ // The last chunk earns its preceding ad only when it carries real content.
+ if (chunks.length > 1) {
+ const last = chunks[chunks.length - 1];
+ if (visibleLength(last.replace(TAG_RE, ' ')) < minChars / 2) {
+ chunks[chunks.length - 2] += last;
+ chunks.pop();
+ }
+ }
+
+ return chunks;
+}
diff --git a/packages/shared/src/features/monetization/ProgrammaticAd.tsx b/packages/shared/src/features/monetization/ProgrammaticAd.tsx
index c9981fae43..e1c13e27c4 100644
--- a/packages/shared/src/features/monetization/ProgrammaticAd.tsx
+++ b/packages/shared/src/features/monetization/ProgrammaticAd.tsx
@@ -321,7 +321,9 @@ export function ProgrammaticAd({
observer.disconnect();
setIsRequested(true);
},
- { rootMargin: '600px' },
+ // Expert-tuned: request just as the slot approaches rather than a
+ // viewport ahead — viewability over prefetch.
+ { rootMargin: '50px' },
);
observer.observe(element);
@@ -483,7 +485,9 @@ export function ProgrammaticAd({
- {/* A fluid native unit is the "confusable with site content" shape
- AdSense prohibits shipping unlabeled — "Advertisements" is one of
- the two label strings its policy permits. Inside the wrapper, so an
- unfilled slot's collapse takes the label down with it. */}
- {isRequested && config.type === 'inFeed' && (
-
+ {/* Every unit is labeled so none can be confused with site content —
+ "Advertisements" is one of the two label strings AdSense permits
+ (a bare "Advertisement" is not). Inside the wrapper, so an unfilled
+ slot's collapse takes the label down with it. */}
+ {isRequested && (
+
Advertisements
)}