Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ __pycache__/
.venv/
venv/
env/
vrbo_products.*
polymarket_markets.*
polymarket_markets_*.*
*_debug.html
*_debug.png
live_results/
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ body:
attributes:
label: What you expected instead
placeholder: >-
96 products, as the README says a category page yields.
Twenty events per listing page, as the README measured (70-92 markets).
validations:
required: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ jobs:
# python:3.12-slim, where the apt half is not a pip package and cannot ride
# in requirements.txt. `chromium` rather than `chrome`, and unlike a
# sibling repo that is not a compromise — this site was measured serving
# the bundled build the full feed.
# the bundled build the full page.
docker:
runs-on: ubuntu-latest
steps:
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,41 @@ from a row count, it leads the section in a blockquote.
client with `requests.post` stubbed; verified by control (red with the
old client).

- **Donor-repo leftovers that described another site as this one.** The
family core arrived from sibling repos about a blogging site, a Q&A site
and a rental site, and some of their prose and runtime strings survived:
- user-visible: Selenium logged "No property cards appeared … this search"
and pyppeteer "No answer cards appeared … this feed"; both now say "No
event tiles … this listing", as Playwright already did. pyppeteer's block
advice claimed its bundled Chromium was "the build this site was
measured refusing" — false here, where it was measured served; it now
names the build and says so. The Scraper API client's block message
cited a sibling's 2026-09-10 datacentre refusal, its `--help`
description a sibling's archive payload and author pages, and its
`--url` help ended in a broken sentence about a topic's answers;
- the sidecar's `stop_reason` for an unrecognised refusal was
`blocked_bot-or-not`, a sibling's challenge-page name; it is now
`blocked_not-served`;
- `output_writer.run_meta` / `finish_run` defaulted `mode` to a sibling's
`"topic"`, which `diff_runs.py` would have refused as not one row per
sku. The engines always pass `--mode`, so no run was affected; the
default is now `"markets"`;
- `diff_runs.py`: its `--price-tolerance-pct` help and three comments
explained a sibling's live counters, per-story languages and missing
lifecycle, and its host-mismatch refusal said the site "serves one story
from several addresses". All now describe markets;
- `captcha_solver.py` said this site refuses a headless browser with a
394-byte "Access Denied" page. The README measures headless and headful
identical and no challenge met; the paragraph now says that;
- `smoke_test.py`: the docstring described a sibling's column, sign-in
widget and a test this repo does not have, and claimed the fixtures
pseudonymise author handles (`make_fixtures.py` rewrites nothing). The
worker-pool test fed a sibling's archive URLs and counted "days"; it now
uses event-page URLs, asserting the same behaviour;
- `.dockerignore` excluded a sibling's output prefix instead of this
repo's `polymarket_markets.*`; the bug-report template's "expected"
placeholder quoted a sibling's 96 products; `SECURITY.md` said the
project has no releases.

## [0.1.1] — 2026-09-16

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ Not because these do not matter, but because they belong somewhere else:

## Supported versions

`main` only. This project has no releases or version tags; fixes land on `main`
and you update by pulling. If you are running an old clone, update before
reporting.
The latest release and `main`. Fixes land on `main` first and ship in the next
tagged release; if you are running an old clone or an older release, update
before reporting.

## If you have leaked a key

Expand Down
19 changes: 7 additions & 12 deletions captcha_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,23 +1138,18 @@ def turnstile_task_for(challenge: CaptchaChallenge) -> dict:
# a JPEG of distorted text and a GET form). Roughly 190 lines of it, and none
# of it is ported here, because this site has no such page.
#
# What this site does instead is refuse a HEADLESS browser. Measured
# 2026-09-10 from five different addresses, four of them residential: one sibling's bot manager
# answers with HTTP 403 and a 394-byte "Access Denied" page carrying a
# reference id — no form, no image, no widget, nothing for a solver to
# answer. And the trigger is the CLIENT rather than the address: the very
# same addresses were served HTTP 200 and the full catalogue by a browser
# with a real window. So the response to a block here is `--headful` or
# `--cdp-endpoint`, not a solve and not a better proxy, and
# product_parser.detect_page_state reports it as "blocked" rather than
# "challenge" precisely so no solve is attempted and nothing is charged.
# Nor does this site refuse a headless browser: headless and headful were
# measured identical on polymarket.com, and no challenge was met in any
# capture (README). What a block would look like here is Cloudflare's — the
# site sits behind it — and a managed challenge renders a Turnstile, which
# the machinery above does implement.
#
# The reCAPTCHA / hCaptcha / Turnstile machinery above IS kept, and that is a
# deliberate asymmetry rather than an inconsistency. Detection stays broad
# because which challenge a visitor meets depends on the exit country and on
# what the address has been doing — a narrow list is how a challenge gets
# reported as an empty page months later. A solver for a challenge this site
# has never been observed to serve is dead code; a DETECTOR for one is cheap
# insurance: a detection that fires on a page whose lots have
# insurance: a detection that fires on a page whose events have
# already rendered guards nothing, which is why the default is
# `when-blocked` and why it counts lot links before it spends.
# `when-blocked` and why it counts event links before it spends.
62 changes: 25 additions & 37 deletions diff_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
-------------
Compares two output files from this project (JSON, as written by
output_writer.save) and reports what changed between them, keyed on `sku` —
the identifier the README tells people to diff on for tracking an answer's
reception over time.
the identifier the README tells people to diff on for tracking a market's
price, book and state over time.

python3 diff_runs.py --old ml.2026-09-01.json \\
--new ml.2026-09-07.json
Expand All @@ -20,8 +20,8 @@
Four buckets, each keyed on sku — here the market's slug:

added — sku present in --new, absent from --old
removed — sku present in --old, absent from --new (deleted or
collapsed, or just off this particular feed run)
removed — sku present in --old, absent from --new (closed or
delisted, or just off this particular listing run)
changed — sku present in both, with a different price, order book,
volume, liquidity, state or question
source_changed — sku present in both with a different value, but also a
Expand All @@ -48,11 +48,6 @@

from output_writer import UNIQUE_BY_SKU_MODES

# What is worth watching on an answer. No price, currency, discount or stock
# anywhere in this list, because this site has none of them — see
# output_writer's docstring for why those columns do not exist on the row
# either.
#
# `title` IS tracked, unusually for this family: it is the QUESTION, and
# Polymarket lets a market be re-worded and re-slugged.
# WHAT COUNTS AS A CHANGE ON THIS SITE, and why each of these and not more.
Expand Down Expand Up @@ -192,10 +187,9 @@ def diff_products(old: List[dict], new: List[dict],
# THE TWO RUNS READ DIFFERENT VIEWS, which is not a change in the
# answer — and on this site this is the bucket that matters most.
#
# Upvotes, views, shares, comments and the question answer count come
# from the payload the view carried, which an archive or author page
# carries and a topic page does not. So a row read off a topic feed
# has no spread and carries its EVENT's volume, and the same market
# `spread`, the volume windows and the on-chain ids come from the
# payload the view carried, which an event page carries and a listing
# does not. So a row read off a listing has no spread and carries its EVENT's volume, and the same market
# read off its own event page has a spread and the MARKET's volume.
# Diffing the two would report every one of them as having appeared
# from nowhere or collapsed by two orders of magnitude.
Expand Down Expand Up @@ -232,18 +226,16 @@ def diff_products(old: List[dict], new: List[dict],
if not field_changes:
continue

# There is no lifecycle bucket on this site, and its absence is a
# measurement rather than an omission. A sibling repo needs one
# because an auction closing moves a bid kind and the amount beside
# it in one event; an answer has no such state machine. What it does
# have -- being deleted or collapsed -- makes it vanish from the
# feed, which is the `removed` bucket. The `lifecycle` key is still
# emitted, always empty, so a consumer written against the family
# diff shape does not have to branch.
# There is no separate lifecycle bucket here: a market's state
# transitions (`active`, `closed`, `accepting_orders`) are TRACKED
# fields, so a market closing lands in `changed` beside everything
# else about it. The `lifecycle` key is still emitted, always empty,
# so a consumer written against the family diff shape does not have
# to branch.

# A counter ticking rather than a real move -- see
# `_within_tolerance`. Only when the ONLY differences are count
# fields: a title or a body length changing alongside is a real
# fields: a title or an end date changing alongside is a real
# change whatever the size of the move.
if (all(f in COUNT_FIELDS for f in field_changes)
and _within_tolerance(before, after, field_changes,
Expand Down Expand Up @@ -374,14 +366,10 @@ def _check_comparable(args) -> bool:
# consistently on a DIFFERENT single host, because then `added` and
# `removed` would be describing the address rather than the catalogue.
#
# There is deliberately no language check, and the reason is worth
# writing down because the sibling repo this file came from has one.
# There, `language` meant which of twenty-four language SITES a row came
# from, and those are different catalogues. Here it is the site's own
# `detectedLanguage` for the STORY — one measured English tag's day
# archive carried 123 `en` rows and 5 `id` ones — so refusing a pair
# whose languages differ would refuse two perfectly comparable runs of
# the same tag.
# There is deliberately no language check: a locale path translates the
# words and leaves the ids and the prices alone (the suite pins this on
# the /es/ fixtures), so two runs of one listing under different locales
# are still the same markets, joined on the same sku.
sources = {}
for label, path in (("--old", args.old), ("--new", args.new)):
try:
Expand All @@ -394,7 +382,7 @@ def _check_comparable(args) -> bool:
if len(set(sources.values())) > 1:
problems.append(
f"the two runs landed on different hosts ({sources}). This site "
f"serves one story from several addresses, so this is usually a "
f"serves every locale from one host, so this is usually a "
f"different URL rather than a different catalogue — but added "
f"and removed would describe the address change rather than "
f"anything about the markets.")
Expand Down Expand Up @@ -428,11 +416,11 @@ def parse_args():
"counter ticking rather than an event: reported "
"separately and ignored by --fail-on-change. Default 0 "
"— report every tick. Unlike in most of this family "
"the flag has a real use here: this site's own "
"clap counts are live, so a monitor watching for an "
"answer taking off wants a threshold, while one "
"watching for an edit wants text_chars, which this "
"never absorbs.")
"the flag has a real use here: a market's price "
"moves continuously, so a monitor watching for a "
"real repricing wants a threshold, while one "
"watching for a market closing wants `closed`, "
"which this never absorbs.")
p.add_argument("--fail-on-change", action="store_true",
help="Exit 1 if anything was added, removed or changed — "
"for a cron job that should only notify on a real diff.")
Expand Down Expand Up @@ -464,7 +452,7 @@ def main() -> int:
print(f"[+] Full diff written to {args.out}")

# Neither `source_changed` nor `within_tolerance` is a reason to fail.
# The first means our two runs read different views of the same answer;
# The first means our two runs read different views of the same market;
# the second means a live counter ticked. Neither says anything about the
# site, and alerting on either would train whoever reads the alert to
# ignore it.
Expand Down
4 changes: 2 additions & 2 deletions output_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ def write_run_meta(out_prefix: str, meta: dict) -> str:
def run_meta(status: str, stop_reason: str, pages_requested: int,
pages_completed: int, start_url: str, final_url: str,
products: int, pages_failed: Optional[List[int]] = None,
mode: str = "topic", source: str = SOURCE_DEFAULT,
mode: str = "markets", source: str = SOURCE_DEFAULT,
extra: Optional[dict] = None) -> dict:
"""Build the metadata dict for a finished run.

Expand Down Expand Up @@ -565,7 +565,7 @@ def finish_run(rows: Sequence[Any], out_prefix: str, fmt: str,
pages_requested: int, pages_completed: int,
start_url: str, final_url: str,
pages_failed: Optional[List[int]] = None,
mode: str = "topic", source: str = SOURCE_DEFAULT,
mode: str = "markets", source: str = SOURCE_DEFAULT,
extra: Optional[dict] = None) -> int:
"""Write output + the run-metadata sidecar; return the exit code.

Expand Down
2 changes: 1 addition & 1 deletion playwright_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@ def _fetch_one_page(session, args, pool, page_num: int, url: Optional[str]) -> P
logger.error("%s", page_flow.block_advice(
html, headless=bool(getattr(args, "headless", False)),
has_pool=has_pool))
outcome.blocked_by = vendor or ("no-response" if not html else "bot-or-not")
outcome.blocked_by = vendor or ("no-response" if not html else "not-served")
outcome.final_url = session.page.url
return outcome

Expand Down
18 changes: 10 additions & 8 deletions puppeteer_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,14 +685,15 @@ def _fetch_one_page(session, args, pool, page_num: int,
debug_html)
if not args.chromium_path and not args.cdp_endpoint:
logger.error(
"This engine launched pyppeteer's OWN Chromium, which is the "
"build this site was measured refusing. Pass --chromium-path "
"pointing at an installed Chrome before concluding anything "
"about the address.")
"This engine launched pyppeteer's OWN Chromium (build "
"117.0.5938.0), the one thing that differs from its twins. "
"polymarket.com was measured serving it, but pass "
"--chromium-path pointing at an installed Chrome before "
"concluding anything about the address.")
logger.error("%s", page_flow.block_advice(
html, headless=bool(getattr(args, "headless", False)),
has_pool=has_pool))
outcome.blocked_by = vendor or ("no-response" if not html else "bot-or-not")
outcome.blocked_by = vendor or ("no-response" if not html else "not-served")
outcome.final_url = _current_url(session)
return outcome

Expand All @@ -713,9 +714,10 @@ def _fetch_one_page(session, args, pool, page_num: int,
content_timeout)
_sleep(500)
if found < threshold:
logger.info("No answer cards appeared within %.0fs. If this feed "
"genuinely holds nothing, that is the expected "
"answer and the run will report 0 rows (exit 4).",
logger.info("No event tiles appeared within %.0fs. If this "
"listing genuinely holds nothing, that is the "
"expected answer and the run will report 0 rows "
"(exit 4).",
content_timeout / 1000)
outcome.scroll = _scroll_the_feed(session, args, html, page_num)
html = _content(session) or html
Expand Down
21 changes: 9 additions & 12 deletions scraper_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,11 @@ def _run_once(args, attempt: int = 1, attempts: int = 1) -> int:
f.write(html)
logger.error(
"The site did not serve the Scraper API's request (upstream "
"HTTP %s, %d bytes) — saved to %s. Measured 2026-09-10: the "
"Scraper API's own exit is a datacentre address, and this site "
"answers those with NOTHING, while the same task routed through "
"a Scraping Browser session returned 200 and 417 KB. Pass "
"--cdp-url. This is exit 3, distinct from an empty result "
"HTTP %s, %d bytes) — saved to %s. All four modes were measured "
"returning HTTP 200 through this API on 2026-09-16, so check the "
"dump for Cloudflare's block page before concluding anything; "
"--cdp-url routes the task through a Scraping Browser session "
"instead. This is exit 3, distinct from an empty result "
"(exit 4).", upstream_status, len(html), dump)
return 3

Expand Down Expand Up @@ -306,10 +306,9 @@ def parse_args():
description="Polymarket market scraper — 2captcha Scraper API edition "
"(no local browser). Measured 2026-09-16 at $0.0005 a "
"request: all four modes returned HTTP 200 and rows "
"identical to a local browser's, including the whole "
"2.3 MB archive-day payload in one request. It cannot "
"scroll, which costs nothing except on an author page — "
"see the module docstring.")
"identical to a local browser's. It cannot walk "
"--mode events, which needs a browser engine's page "
"loop — see the module docstring.")
# NOT required: prefer the TWOCAPTCHA_KEY env var. A key passed on the
# command line is visible to anyone who can run `ps`, and it lands in
# shell history and in any log that echoes the command line.
Expand All @@ -318,9 +317,7 @@ def parse_args():
"Defaults to $TWOCAPTCHA_KEY, which is the safer way to pass it.")
p.add_argument("--url", default=None,
help="A Polymarket listing or event URL. "
"returns nothing here — measured, and --wait-element "
"does not help — because a topic's answers arrive "
"over a later XHR. Required, unless POLYMARKET_URL is set "
"Required, unless POLYMARKET_URL is set "
"in the environment or in .env.")
p.add_argument("--category", default=None, help="Label to tag output rows with. Defaults to the category segment of the URL, so the column is never empty just because the flag was omitted.")
p.add_argument("--format", choices=["json", "csv", "both"], default="both")
Expand Down
Loading
Loading