# crawler-lib-php
AI Summary
Purpose:
- Durable note for the Packagist/Composer package crawler at
~/labrador/crawler/crawler-lib-php. - Records the 2026-06-08 missing-data investigation (root-cause analysis only; no crawler code changed yet) and the standalone missing-product finder tool built at
~/labrador/tool/composer_missing_products.
Key points:
- The crawler is Python (despite the
phpname) and collects PHP/Composer packages from Packagist.LANGUAGE='php',REPOSITORY='COMPOSER'. - Package intake:
packageListCrawlerdiffshttps://packagist.org/packages/list.jsonagainst the LIST table;updatePackageListCrawler/newPackageListCrawleradd from thereleases.rss/packages.rssfeeds. Per-package metadata comes fromhttps://packagist.org/packages/{product}.json. - Work-list table is
gatheringdb.TB_COMP_LIB_PHP_LIST(PROCESSED flag); product table isTB_COMP_LIB_PRODUCT(php/COMPOSER); version tableTB_COMP_LIB_VERSION_PHP; license recordTB_COMP_LIB_LICENSE_PHP. - 2026-06-08 finding (measured): Packagist lists 449,777 packages; DB has 509,737 php/COMPOSER products; intersection 436,674; 13,103 packages are on Packagist but missing from the product table; 73,063 are DB-only (Packagist-deleted/stale). Sampled missing packages return HTTP 200 on Packagist and are absent from DB → genuine omissions, not false positives.
- Cause breakdown of the 13,103: 13,058 (99.7%) have NO version rows either = never made it into the product table at all (work-list non-entry or whole-package crawl failure); only 45 (0.3%) have version rows but no product = product-creation-step omission (latest version was
dev-*or the last version errored). In a 40-package sample of the no-version group: ~57% latest-released in 2025+ (intake not keeping up), ~42% older (errored and never retried), 25%dev-only. - 2026-06-08 follow-up: crawler-side fixes WERE applied (retry/transient handling, list.json timeout, dev-only rescue, libraries.io removal, resync ORDER BY, malformed-authors hardening). Data row format is unchanged. See "Crawler Fixes Applied (2026-06-08)" below.
Relevant when:
- Diagnosing missing Composer packages or versions.
- Working on
packageListCrawler/updatePackageListCrawler/dataCrawling/ LIST processing / Packagist metadata mapping. - Running or extending the
composer_missing_productsfinder.
Do not read full document unless:
- You need the exact omission root causes, the finder tool usage, or verification numbers.
Linked documents:
ai/workspace/repos.mdai/worklog/2026/2026-W24.mdai/repo-notes/crawler-lib-dotnet.md(sibling NuGet crawler; same code lineage and known gaps)- Tool:
~/labrador/tool/composer_missing_products/(finder)
Open Questions
- [RESOLVED 2026-06-08] transient failures no longer terminal —
TransientFetchErrorkeepsPROCESSED=0. See fix #2. - [RESOLVED 2026-06-08]
list.jsontimeout raised to 30s with retry. See fix #1. (Monitor whether 30s is enough for the multi-MB response in prod.) - [RESOLVED 2026-06-08] dev-only packages now collected via
all_devrescue. See fix #3. (Packages whose latest sorted version is dev but that ALSO have real versions still build product from the latest real version, since sort-gen orders dev to the front.) - Dependency table writes are still commented out (
798c626 "디펜던시 테이블 수집 정지"); re-enable, or is version-rowDEPENDENCIESJSON enough? (Same open decision as the dotnet crawler.) executeSqlstill swallows insert errors (logs, no re-raise) — NOT changed; see "Recommended but NOT applied". Should a failed version/product batch block the LIST row fromPROCESSED=1?- Tests run on host via stubs + the composer tool venv; no live Docker run or live re-crawl was executed this session. Run
python3 -m unittest discover -s testsin the Docker image before deploy.
Details
Repo Info
- Repo ID:
crawler-lib-php - Local path:
~/labrador/crawler/crawler-lib-php - Language: Python 3 crawler (Docker), Java
sort-genjar via JPype for version sort - Ecosystem: Packagist / Composer (
LANGUAGE='php',REPOSITORY='COMPOSER') - Branch:
master
Pipeline
run() → intake (list.json + 2 RSS feeds, incremental only) → paginate LIST → dataCrawling() per package → insert version / product / license.
- Incremental:
where='processed = 0', offset pinned at 0 each loop (theprocessed=0set shrinks as packages are marked done). - Resync:
where=None, offset climbs (LIMIT :start, 100), resumes fromCRAWLING_PACKAGE_NUMinTB_CRAWLER_STATUS.
Missing-Data Root Causes (2026-06-08 analysis — NOT yet fixed)
- Transient HTTP failure → permanent incremental exclusion (dominant).
getJsonRequests (src/ai/labradorlabs/sw/common.py) uses timeout=5, no retry, no try/except. In dataCrawling, a non-200 (404/429/5xx) → getPackageListVo(..., 5, 1) (PROCESSED=5); an exception or bad shape → status 3 or 10. All of these are non-zero, so the package is never re-selected by incremental WHERE processed = 0. Only a full resync recovers it. This matches the ~42% "older, still missing" sample slice.
list.jsonfull-sync likely times out.
packageListCrawler fetches the multi-MB list.json with the same timeout=5 → almost always times out → caught and logged, 0 new packages added that run. New packages then enter only via the small RSS feeds, so packages outside the feed window never enter the LIST. Matches the ~57% "recently released, missing" slice.
dev-*skip can drop whole packages / product rows.
In the version loop: if len(versions)>1 and version[:3]=='dev': continue. A package whose versions are all dev-* (and >1) has every version skipped → no version rows AND no product. A package whose latest sorted version (versions[-1]) is dev-* reaches continue before the if version == versions[-1] product block → version rows exist but no product (the 45-package group). Depends on the Java sort-gen ordering of dev branches.
- Per-version exception silently drops that version (per-version try/except) while the package can still look processed.
- Insert failures are swallowed.
cdbvdb.executeSqlcatches and logs but does not re-raise; version/product/list are separate inserts, and the LIST row (processed=1) is its own insert — so a failed version batch can be lost while the package is marked done.
- Resync offset pagination has no
ORDER BY(SELECT ... LIMIT :start, 100) → risk of skipped/duplicated rows across pages during a resync.
- Dependency table writes are intentionally disabled (commented out; commit
798c626).DEPENDENCIESJSON in the version row is still populated.
Crawler Fixes Applied (2026-06-08)
All changes preserve the version/product/license row format (columns + JSON encodings). They change fetch robustness, LIST PROCESSED semantics, version selection, and remove libraries.io. Verified: tests/test_missing_regressions.py 12/12 + existing tests/test_check.py 10/10; all changed modules byte-compile. Files: sw/common.py, crawler/phpCrawler.py, crawler/component.py, db/gatheringDBVo.py, util/licenseeUtil.py, input/crawlerInfo.py.
getJsonRequestsrobustness (sw/common.py) — wastimeout=5, no retry, no try/except. Now:timeout=30, retry ladder (backoff), 429Retry-After, retries on timeout/connection/5xx. ReturnsNoneonly on 404/410 (permanent); raises newTransientFetchErrorwhen transient failures persist. Fixes the multi-MBlist.jsonalways-timing-out and transient per-package failures.- Transient ≠ terminal (
crawler/phpCrawler.dataCrawling) — addedexcept TransientFetchErrorthat records the LIST row asPROCESSED=0(retryable) instead of3/5. This is the #1 missing cause: a one-off 429/timeout no longer parks a package out of the incrementalWHERE processed = 0set forever. - dev-only rescue (
phpCrawler.dataCrawling) —all_dev = all(v[:3]=='dev' for v in versions); the dev-skip (len>1 and v[:3]=='dev') is now applied only when NOT all-dev. All-devpackages (which the crawler previously dropped entirely → no version/product) are now collected. Mixed packages keep prior behavior. (Matches the backfill tool's--include-dev-only.) - malformed-authors / require hardening (
crawler/component.py) —getOrganizationnow skips non-dict author entries (e.g.authors: [[]]no longer raisesAttributeErrorand drops the version);getDependencyFirst/Secondguardisinstance(dict). This was ~10% of backfill fetch failures. Output format identical. - libraries.io removed (user-requested) — deleted
component.getLibrariesGitUrl,gatheringDBVo.getVersionLicenseWithLibrariesVo,licenseeUtil.getLibrariesRepoUrl(which contained a hardcoded libraries.io API key — now gone), and the two libraries.io blocks inphpCrawler.dataCrawling. License now comes from GitHub clone + packagist only. TheLIBRARIEScolumn inTB_COMP_LIB_LICENSE_PHPsimply stays unpopulated (schema/format unchanged). - resync pagination
ORDER BY(input/crawlerInfo.py) —LIMIT_SELECT_QUERYnow... ORDER BY PATH LIMIT :start, 100. Without a stable order, offset pagination during resync could skip/duplicate rows. PATH is the LIST PK so the order uses the index.
Round 2 (2026-06-08, user: "packageList 있으면 github 굳이 하지마", "비판적인 것 다 개선", "누락 0", data format MUST NOT change)
- License priority flipped to packagist-first + lazy GitHub (
phpCrawler.dataCrawling,gatheringDBVo.getVersionLicenseVo,component.getOriginalDataAndGitLicense) — was GitHub-first (clone+licensee always run). Now: if packagistversion.licensestandardizes (version_dict['STANDARD_LICENSE']not None) the package uses it and GitHub is skipped entirely (skip_github=True); the git clone is done lazily only on the first version that lacks a packagist license.getVersionLicenseVonow picks PACKAGE_REPOSITORY (packagist) first, GITHUB only as fallback. AI-license input is likewise packagist-first (clone license-file text only when packagist absent). Big perf win (no clone for the common case) and matches the user's request. Row/JSON format unchanged:version_license_dictkeys andORIGINAL_DATA={PR,GITHUB:{URL,TAG,LICENSEE_TEXT}}structure identical; only the STANDARD_LICENSE value source changes. - Insert atomicity (
cdbvdb.executeSql,baseCralwer.setInsertInto/IgnoreDB,phpCrawler.dataCrawling) —executeSqland the twosetInsert*now re-raise instead of swallowing.dataCrawlingwraps version/product/license inserts in try/except and inserts the LISTpackage_list(thePROCESSEDmarkers) only after they succeed; on failure it returns without marking, so the batch staysPROCESSED=0and is retried (idempotent upsert). Removes the silent "data lost but LIST=PROCESSED=1" omission. Trade-off: a systematic insert failure now wedges progress (loud) instead of silently dropping — correct for a no-omission goal; the row format is DB-compatible (the backfill tool upserts the same rows fine). getReleaseDatecorrected (component.py) —time[:time.find('+')-1](could drop the seconds digit) →time[:19]. Same stored format'YYYY-MM-DD HH:MM:SS', correct value, handles+00:00/Z/no-tz.- Scheduler every 3h (
app/main.py) — daily 00:00 →schedule.every(3).hours, so intake/retry backlog clears faster (with fix #1 this keeps the catalog current). insertErrorLogsignature hardened (baseCralwer.py) — now acceptstrx_infoand usesisinstance(list)(wastype[list], a bug). Prevents aTypeErrornow that the insertexceptpaths are live (fix #8). DB persistence toTB_CRAWLER_ERROR_LOGstill intentionally off (schema unconfirmed).
Round-2 verified: tests/test_missing_regressions.py 15/15 (now also: packagist-first license skips github, getOriginalDataAndGitLicense(skip_github) doesn't call licensee, getReleaseDate keeps full seconds) + test_check.py 10/10; full src byte-compiles; no dangling refs.
Round 3 (2026-06-08, intake cadence — user: "하루 1회는 안 됨, 배치 단위?")
Measured: releases.rss covers only ~8 minutes (41 items @ ~293 releases/hr ≈ 7,000/day); packages.rss ~9h. Daily/3h runs missed ~99% of new versions — a major omission source. One incremental run already drains the entire processed=0 set, so cadence + feed-window (not batch size) drive omission.
- changes.json cursor intake (
phpCrawler.changesCrawler,crawlerInfo.CHANGES_URL,common.getJsonAllowStatus) — replaced RSS feeds (updatePackageListCrawler/newPackageListCrawlerremoved) with Packagist's cursor-basedmetadata/changes.json?since=<cursor>: everyupdate/deletesince the cursor + a new cursor, no window loss regardless of run gaps. Cursor inTB_CRAWLER_STATUS(TYPEPhpCralwer_CHANGES).update→LISTprocessed=0,delete→processed=5,deprecated=1; name =action.package.rsplit('~',1)[0]. Bootstrap stores only the current cursor (history via list.json/resync).sincetoo old → 400 → cursor reset + log to resync. Transient → cursor not advanced (next run retries samesince, lossless).getJsonAllowStatusreads the 400 body that carries the cursor. - list.json reconcile gated ~daily (
packageListCrawler+LIST_SYNC_INTERVAL_SEC) — the 450k-name reconcile no longer runs every incremental; gated by aPhpCralwer_LISTSYNCepoch (default 24h). Stays the new-package safety net. - Scheduler every 5 min (
app/main.py) — daily/3h → 5 min; changes.json is cheap and list.json is gated, so no 450k re-download per run. - SELECT batch 100 → 300 (
crawlerInfo.LIMIT_SELECT_QUERY) — fewer round-trips (chunk size only; one run still drains allprocessed=0).
Round-3 verified: tests/test_missing_regressions.py 21/21 (adds changesCrawler bootstrap / update+delete dedup + ~-strip / since-too-old reset / transient-no-advance, and getJsonAllowStatus 200·400·transient) + test_check.py 10/10; full src byte-compiles. Tests stub versionSort (JVM//workspace jar) to import phpCrawler on host; changesCrawler doesn't use it.
Round 4 (2026-06-08, product-only omission)
- *product created from last processed version, not strictly
versions[-1](phpCrawler.dataCrawling, committedcbd6019)* —sort-gencan sort adev-*branch ABOVE real versions (e.g.superconductor/tools:20241105.* < dev-main). The oldif version == versions[-1]then landed on the dev version, which dev-skipcontinues → version rows existed but NO product (the "45 version-only" omission class). NowdataCrawlingtracks the last processed version and builds the product from it after the loop.getProductVounchanged → product row format/fields identical;LATEST_VERSION= highest real version. (Same bug + fix was found in the backfill tool while completing the run.)
Backfill outcome (2026-06-08)
The 13,103 missing products were backfilled into TB_COMP_LIB_PRODUCT/TB_COMP_LIB_VERSION_PHP via composer_missing_products/collect_composer_missing.py (LICENSE_AI filled through an SSH tunnel to the AI API). Final: 13,033 / 13,103 (99.5%) now have product rows; the remaining 70 are unbackfillable — 66 are registered on Packagist's list but have zero published versions, 4 are deleted (404). Two tool bugs were found and fixed during completion: (a) a package whose every version errored was wrongly marked done (conflated with dev-skip-empty) → permanent skip; (b) the same versions[-1]/dev product-omission as crawler fix #16.
Still NOT applied
- Dependency table writes remain commented out (
798c626) — version-rowDEPENDENCIESJSON still populated. Open decision (same as dotnet). TB_CRAWLER_ERROR_LOGpersistence still off (schema unconfirmed).releases.rss/packages.rssconfig keys remain incrawlerInfo(unused now) — harmless.
Missing-Product Finder Tool
~/labrador/tool/composer_missing_products/ (sibling of nuget_missing_products / maven_missing_products).
extract_composer_missing_products.py— Packagist is one-shot:list.json(1 request, ~450k names) vsSELECT LOWER(product_key) FROM TB_COMP_LIB_PRODUCT WHERE language='php' AND repository='COMPOSER'(1 query) → in-memory set diff. No catalog page walk needed (unlike NuGet). ~48s.
- Out: composer_missing_products.txt (Packagist-only = crawler omissions), composer_db_only.txt (DB-only = deleted/stale candidates).
analyze_missing.py— classifies the missing list: batches the missing keys againstTB_COMP_LIB_VERSION_PHPto split "version rows exist / product missing" vs "nothing at all", then samples the no-version group against Packagist for release recency +dev-only.collect_composer_missing.py— backfill collector. Packagist gives all version detail in ONE request (packages/{id}.json), so there is no NuGet-style phase2 leaf fetch — single phase: per package → product(latest) + all version rows.--sink file|db|both,--no-ai, resumable viaout_done.txt(only written AFTER DB commit), 404→composer_gone_404.txt, errors→composer_errors.txt.- The collector is format/behavior-identical to the crawler (per user "포맷팅 전혀 바꾸지말고"): same
sort-gen-0.5.0-lib.jarvia jpype for version sort (envPHP_SORTGEN_JAR), same dev-skip rule (len(versions)>1 and version[:3]=='dev'), samegetVersionVo/getProductVorow shape and license pipeline (TB_LICENSE_V2match + same AI API). Verified against an existing DB row (monolog:STANDARD_LICENSE [{"name":"MIT License",...}],LICENSE_IDS [247],LATEST_VERSION 3.10.0). Packages whose versions are alldev-*produce 0 rows exactly like the crawler and are logged tocomposer_devskip_empty.txt. - Unavoidable (same result): RELEASE_DATE uses robust ISO parse (the crawler's
time[:find('+')-1]slice can truncate seconds; robust parse matches stored values); license input is packagistversion.licenseonly (crawler's packagist fallback path — no git-clone text extraction / libraries.io). - DB config reuses
~/labrador/tool/data_check_script/.env(this connection cannot seegatheringdb, only the product/version DB — so the finder compares at the product grain, like the NuGet tool).LICENSE_AIonly populates when the internal AI API (211.115.125.171) is reachable. - A notable share of the 13,103 are all-
dev-version packages the crawler deliberately drops via the dev-skip rule (e.g.04l3x4ndr3/sdk-omie= onlydev-main/dev-development) — these surface incomposer_devskip_empty.txt. Whether to backfill them (i.e. relax dev-skip) is a product decision, not a crawler bug.